Expanded only where a Flex can hold it
"Cannot hit test a render box that has never been laid out", in a pile, and then a failed assertion on semantics. All one cause: `hbox` renders as a Wrap, `entry` with no width returned `Expanded`, and `Expanded` outside a Flex fails the layout — after which every box under it is asked to hit-test without ever having been laid out. The chats screen has two unsized entries in an `hbox`, which is where it fired. The renderer now threads the axis it is building into, because there is no way to ask Flutter after the fact. An unsized entry takes the rest of the row where there is a row and a definite width otherwise; a `fillHeight` vbox and a `scroll` expand only into a Flex. A TextField has no intrinsic width, so in a Wrap it was both illegal and unmeasurable, which is why one field took the whole screen down rather than just itself. A row whose child asks to fill the height now stretches on its cross axis, which is where that has to come from — Expanded in a Row is about width. FRQ_AUTOCONNECT comes back with it, deleted along with the spike it was written for. It is how this was found at all: the connect screen is clean because its entries carry widths, so a window that only ever reaches that screen looks fine. A Wayland window cannot be clicked from a script, and sitting in front of one is not a check. Not yet verified past the connect screen — that is the next thing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
f1e99b4 parent: 2e24e64 modified
flutter/lib/nim_renderer.dart +50 -15 | @@ -134,9 +134,30 @@ class _NimAppState extends State<NimApp> { | ||
| 134 | 134 | |
| 135 | 135 | // ------------------------------------------------------------------ build |
| 136 | 136 | |
| 137 | - Widget _build(core.UiNode n) { | |
| 138 | - final kids = n.children.map(_build).toList(); | |
| 137 | + /// The axis of the widget a node is being built *into*, because `Expanded` | |
| 138 | + /// is only legal inside a Flex and there is no way to ask Flutter after the | |
| 139 | + /// fact. | |
| 140 | + /// | |
| 141 | + /// Getting this wrong is what "Cannot hit test a render box that has never | |
| 142 | + /// been laid out" means, in a pile: an `Expanded` inside a `Wrap` fails the | |
| 143 | + /// layout, and every box under it is then asked to hit-test without ever | |
| 144 | + /// having been laid out. The chats screen did exactly that — two unsized | |
| 145 | + /// entries in an `hbox`, which is a Wrap. | |
| 146 | + static const _noAxis = ''; | |
| 147 | + static const _row = 'row'; | |
| 148 | + static const _column = 'column'; | |
| 149 | + | |
| 150 | + Widget _build(core.UiNode n, [String axis = _noAxis]) { | |
| 139 | 151 | final spacing = _d(n.props['spacing'], 0); |
| 152 | + final flex = axis == _row || axis == _column; | |
| 153 | + | |
| 154 | + // What this node's own children are being built into. | |
| 155 | + final childAxis = switch (n.tag) { | |
| 156 | + 'page' || 'vbox' || 'card' || 'scroll' || 'dialog' => _column, | |
| 157 | + 'hbox' => n.prop('wrap', true) ? _noAxis : _row, | |
| 158 | + _ => _noAxis, | |
| 159 | + }; | |
| 160 | + final kids = n.children.map((c) => _build(c, childAxis)).toList(); | |
| 140 | 161 | |
| 141 | 162 | switch (n.tag) { |
| 142 | 163 | case 'page': |
| @@ -166,8 +187,11 @@ class _NimAppState extends State<NimApp> { | ||
| 166 | 187 | final w = _d(n.props['widthRequest'], 0); |
| 167 | 188 | if (w > 0) col = SizedBox(width: w, child: col); |
| 168 | 189 | // `fillHeight` is what keeps the compose bar at the bottom instead |
| 169 | - // of wherever the backlog happens to end. | |
| 170 | - return n.prop('fillHeight', false) ? Expanded(child: col) : col; | |
| 190 | + // of wherever the backlog happens to end — but only a Flex can be | |
| 191 | + // told to expand into. | |
| 192 | + return (n.prop('fillHeight', false) && flex) | |
| 193 | + ? Expanded(child: col) | |
| 194 | + : col; | |
| 171 | 195 | } |
| 172 | 196 | |
| 173 | 197 | case 'hbox': |
| @@ -179,12 +203,18 @@ class _NimAppState extends State<NimApp> { | ||
| 179 | 203 | final wrapping = n.prop('wrap', true); |
| 180 | 204 | final align = n.prop('align', 'center'); |
| 181 | 205 | if (!wrapping) { |
| 206 | + // A child asking to fill the height gets it from the row's cross | |
| 207 | + // axis, not from an Expanded — Expanded in a Row is about width. | |
| 208 | + final stretches = | |
| 209 | + n.children.any((c) => c.prop('fillHeight', false)); | |
| 182 | 210 | return _margins( |
| 183 | 211 | n, |
| 184 | 212 | Row( |
| 185 | - crossAxisAlignment: align == 'end' | |
| 186 | - ? CrossAxisAlignment.end | |
| 187 | - : CrossAxisAlignment.center, | |
| 213 | + crossAxisAlignment: stretches | |
| 214 | + ? CrossAxisAlignment.stretch | |
| 215 | + : (align == 'end' | |
| 216 | + ? CrossAxisAlignment.end | |
| 217 | + : CrossAxisAlignment.center), | |
| 188 | 218 | children: _spaced(kids, spacing, vertical: false), |
| 189 | 219 | ), |
| 190 | 220 | ); |
| @@ -475,10 +505,15 @@ class _NimAppState extends State<NimApp> { | ||
| 475 | 505 | onSubmitted: (_) => _send(n.prop('onSubmit', '')), |
| 476 | 506 | ); |
| 477 | 507 | final w = _d(n.props['widthRequest'], 0); |
| 478 | - // A width request is a minimum in the screens' vocabulary, but here | |
| 479 | - // it has to be a maximum too: an unconstrained TextField inside a | |
| 480 | - // Wrap has no width at all to take. | |
| 481 | - return w > 0 ? SizedBox(width: w, child: field) : Expanded(child: field); | |
| 508 | + if (w > 0) return SizedBox(width: w, child: field); | |
| 509 | + // No width asked for: take the rest of the row where there is a row | |
| 510 | + // to take it from, and otherwise a definite width. NOT Expanded | |
| 511 | + // unconditionally — a TextField has no intrinsic width, so in a Wrap | |
| 512 | + // it is both illegal and unmeasurable, and that combination is what | |
| 513 | + // took the whole screen down rather than one field. | |
| 514 | + return axis == _row | |
| 515 | + ? Expanded(child: field) | |
| 516 | + : SizedBox(width: 320, child: field); | |
| 482 | 517 | } |
| 483 | 518 | |
| 484 | 519 | case 'scroll': |
| @@ -493,10 +528,10 @@ class _NimAppState extends State<NimApp> { | ||
| 493 | 528 | body = Scrollbar(child: body); |
| 494 | 529 | final h = _d(n.props['height'], 0); |
| 495 | 530 | if (h > 0) return SizedBox(height: h, child: body); |
| 496 | - // No fixed height: take what the column has left. `reserve` is the | |
| 497 | - // Clojure's way of saying the same thing to a backend that could not | |
| 498 | - // do this, and is ignored here on purpose. | |
| 499 | - return Expanded(child: body); | |
| 531 | + // No fixed height: take what the column has left, where there is a | |
| 532 | + // column. `reserve` is the Clojure's way of saying the same thing to | |
| 533 | + // a backend that could not do this, and is ignored here on purpose. | |
| 534 | + return flex ? Expanded(child: body) : SizedBox(height: 400, child: body); | |
| 500 | 535 | } |
| 501 | 536 | |
| 502 | 537 | /// A panel over the screen rather than a screen of its own. |
| @@ -134,9 +134,30 @@ class _NimAppState extends State<NimApp> { | |||
| 134 | 134 | ||
| 135 | // ------------------------------------------------------------------ build | 135 | // ------------------------------------------------------------------ build |
| 136 | 136 | ||
| 137 | - Widget _build(core.UiNode n) { | 137 | + /// The axis of the widget a node is being built *into*, because `Expanded` |
| 138 | - final kids = n.children.map(_build).toList(); | 138 | + /// is only legal inside a Flex and there is no way to ask Flutter after the |
| 139 | + /// fact. | ||
| 140 | + /// | ||
| 141 | + /// Getting this wrong is what "Cannot hit test a render box that has never | ||
| 142 | + /// been laid out" means, in a pile: an `Expanded` inside a `Wrap` fails the | ||
| 143 | + /// layout, and every box under it is then asked to hit-test without ever | ||
| 144 | + /// having been laid out. The chats screen did exactly that — two unsized | ||
| 145 | + /// entries in an `hbox`, which is a Wrap. | ||
| 146 | + static const _noAxis = ''; | ||
| 147 | + static const _row = 'row'; | ||
| 148 | + static const _column = 'column'; | ||
| 149 | + | ||
| 150 | + Widget _build(core.UiNode n, [String axis = _noAxis]) { | ||
| 139 | final spacing = _d(n.props['spacing'], 0); | 151 | final spacing = _d(n.props['spacing'], 0); |
| 152 | + final flex = axis == _row || axis == _column; | ||
| 153 | + | ||
| 154 | + // What this node's own children are being built into. | ||
| 155 | + final childAxis = switch (n.tag) { | ||
| 156 | + 'page' || 'vbox' || 'card' || 'scroll' || 'dialog' => _column, | ||
| 157 | + 'hbox' => n.prop('wrap', true) ? _noAxis : _row, | ||
| 158 | + _ => _noAxis, | ||
| 159 | + }; | ||
| 160 | + final kids = n.children.map((c) => _build(c, childAxis)).toList(); | ||
| 140 | 161 | ||
| 141 | switch (n.tag) { | 162 | switch (n.tag) { |
| 142 | case 'page': | 163 | case 'page': |
| @@ -166,8 +187,11 @@ class _NimAppState extends State<NimApp> { | |||
| 166 | final w = _d(n.props['widthRequest'], 0); | 187 | final w = _d(n.props['widthRequest'], 0); |
| 167 | if (w > 0) col = SizedBox(width: w, child: col); | 188 | if (w > 0) col = SizedBox(width: w, child: col); |
| 168 | // `fillHeight` is what keeps the compose bar at the bottom instead | 189 | // `fillHeight` is what keeps the compose bar at the bottom instead |
| 169 | - // of wherever the backlog happens to end. | 190 | + // of wherever the backlog happens to end — but only a Flex can be |
| 170 | - return n.prop('fillHeight', false) ? Expanded(child: col) : col; | 191 | + // told to expand into. |
| 192 | + return (n.prop('fillHeight', false) && flex) | ||
| 193 | + ? Expanded(child: col) | ||
| 194 | + : col; | ||
| 171 | } | 195 | } |
| 172 | 196 | ||
| 173 | case 'hbox': | 197 | case 'hbox': |
| @@ -179,12 +203,18 @@ class _NimAppState extends State<NimApp> { | |||
| 179 | final wrapping = n.prop('wrap', true); | 203 | final wrapping = n.prop('wrap', true); |
| 180 | final align = n.prop('align', 'center'); | 204 | final align = n.prop('align', 'center'); |
| 181 | if (!wrapping) { | 205 | if (!wrapping) { |
| 206 | + // A child asking to fill the height gets it from the row's cross | ||
| 207 | + // axis, not from an Expanded — Expanded in a Row is about width. | ||
| 208 | + final stretches = | ||
| 209 | + n.children.any((c) => c.prop('fillHeight', false)); | ||
| 182 | return _margins( | 210 | return _margins( |
| 183 | n, | 211 | n, |
| 184 | Row( | 212 | Row( |
| 185 | - crossAxisAlignment: align == 'end' | 213 | + crossAxisAlignment: stretches |
| 186 | - ? CrossAxisAlignment.end | 214 | + ? CrossAxisAlignment.stretch |
| 187 | - : CrossAxisAlignment.center, | 215 | + : (align == 'end' |
| 216 | + ? CrossAxisAlignment.end | ||
| 217 | + : CrossAxisAlignment.center), | ||
| 188 | children: _spaced(kids, spacing, vertical: false), | 218 | children: _spaced(kids, spacing, vertical: false), |
| 189 | ), | 219 | ), |
| 190 | ); | 220 | ); |
| @@ -475,10 +505,15 @@ class _NimAppState extends State<NimApp> { | |||
| 475 | onSubmitted: (_) => _send(n.prop('onSubmit', '')), | 505 | onSubmitted: (_) => _send(n.prop('onSubmit', '')), |
| 476 | ); | 506 | ); |
| 477 | final w = _d(n.props['widthRequest'], 0); | 507 | final w = _d(n.props['widthRequest'], 0); |
| 478 | - // A width request is a minimum in the screens' vocabulary, but here | 508 | + if (w > 0) return SizedBox(width: w, child: field); |
| 479 | - // it has to be a maximum too: an unconstrained TextField inside a | 509 | + // No width asked for: take the rest of the row where there is a row |
| 480 | - // Wrap has no width at all to take. | 510 | + // to take it from, and otherwise a definite width. NOT Expanded |
| 481 | - return w > 0 ? SizedBox(width: w, child: field) : Expanded(child: field); | 511 | + // unconditionally — a TextField has no intrinsic width, so in a Wrap |
| 512 | + // it is both illegal and unmeasurable, and that combination is what | ||
| 513 | + // took the whole screen down rather than one field. | ||
| 514 | + return axis == _row | ||
| 515 | + ? Expanded(child: field) | ||
| 516 | + : SizedBox(width: 320, child: field); | ||
| 482 | } | 517 | } |
| 483 | 518 | ||
| 484 | case 'scroll': | 519 | case 'scroll': |
| @@ -493,10 +528,10 @@ class _NimAppState extends State<NimApp> { | |||
| 493 | body = Scrollbar(child: body); | 528 | body = Scrollbar(child: body); |
| 494 | final h = _d(n.props['height'], 0); | 529 | final h = _d(n.props['height'], 0); |
| 495 | if (h > 0) return SizedBox(height: h, child: body); | 530 | if (h > 0) return SizedBox(height: h, child: body); |
| 496 | - // No fixed height: take what the column has left. `reserve` is the | 531 | + // No fixed height: take what the column has left, where there is a |
| 497 | - // Clojure's way of saying the same thing to a backend that could not | 532 | + // column. `reserve` is the Clojure's way of saying the same thing to |
| 498 | - // do this, and is ignored here on purpose. | 533 | + // a backend that could not do this, and is ignored here on purpose. |
| 499 | - return Expanded(child: body); | 534 | + return flex ? Expanded(child: body) : SizedBox(height: 400, child: body); |
| 500 | } | 535 | } |
| 501 | 536 | ||
| 502 | /// A panel over the screen rather than a screen of its own. | 537 | /// A panel over the screen rather than a screen of its own. |
modified
nim/src/frq/reducer.nim +23 -1 | @@ -11,7 +11,7 @@ | ||
| 11 | 11 | ## payload: the alternative is a second serialisation to define and version, |
| 12 | 12 | ## for arguments that are always one string. |
| 13 | 13 | |
| 14 | -import std/[json, options, sequtils, strutils, tables] | |
| 14 | +import std/[json, options, os, sequtils, strutils, tables] | |
| 15 | 15 | import std/sets |
| 16 | 16 | import frq/[cells, model, rooms, reactions, edits, trace, ircparse, clock, |
| 17 | 17 | atproto, handshake] |
| @@ -426,3 +426,25 @@ proc drain*() = | ||
| 426 | 426 | |
| 427 | 427 | else: |
| 428 | 428 | trace("skip", p.command & " " & $p.params) |
| 429 | + | |
| 430 | + | |
| 431 | +# -------------------------------------------------------------- autoconnect | |
| 432 | +# | |
| 433 | +# `FRQ_AUTOCONNECT=1` presses Connect on the first render, and `FRQ_NICK` | |
| 434 | +# overrides the nickname. In the same spirit as FRQ_TRACE and for the same | |
| 435 | +# reason: a GUI on Wayland cannot be clicked from a script, so without this the | |
| 436 | +# only way to check that the window gets past the connect screen is to sit in | |
| 437 | +# front of it — which is not a check, and is how a layout error on the chats | |
| 438 | +# screen went unnoticed while the connect screen looked fine. | |
| 439 | + | |
| 440 | +var autoconnectDone = false | |
| 441 | + | |
| 442 | +proc maybeAutoconnect*() = | |
| 443 | + if autoconnectDone: return | |
| 444 | + autoconnectDone = true | |
| 445 | + let want = getEnv("FRQ_AUTOCONNECT") | |
| 446 | + if want.len == 0 or want == "0": return | |
| 447 | + let nick = getEnv("FRQ_NICK") | |
| 448 | + if nick.len > 0: app.formNick = nick | |
| 449 | + trace("auto", "FRQ_AUTOCONNECT set — connecting as " & app.formNick) | |
| 450 | + connectNow() | |
| @@ -11,7 +11,7 @@ | |||
| 11 | ## payload: the alternative is a second serialisation to define and version, | 11 | ## payload: the alternative is a second serialisation to define and version, |
| 12 | ## for arguments that are always one string. | 12 | ## for arguments that are always one string. |
| 13 | 13 | ||
| 14 | -import std/[json, options, sequtils, strutils, tables] | 14 | +import std/[json, options, os, sequtils, strutils, tables] |
| 15 | import std/sets | 15 | import std/sets |
| 16 | import frq/[cells, model, rooms, reactions, edits, trace, ircparse, clock, | 16 | import frq/[cells, model, rooms, reactions, edits, trace, ircparse, clock, |
| 17 | atproto, handshake] | 17 | atproto, handshake] |
| @@ -426,3 +426,25 @@ proc drain*() = | |||
| 426 | 426 | ||
| 427 | else: | 427 | else: |
| 428 | trace("skip", p.command & " " & $p.params) | 428 | trace("skip", p.command & " " & $p.params) |
| 429 | + | ||
| 430 | + | ||
| 431 | +# -------------------------------------------------------------- autoconnect | ||
| 432 | +# | ||
| 433 | +# `FRQ_AUTOCONNECT=1` presses Connect on the first render, and `FRQ_NICK` | ||
| 434 | +# overrides the nickname. In the same spirit as FRQ_TRACE and for the same | ||
| 435 | +# reason: a GUI on Wayland cannot be clicked from a script, so without this the | ||
| 436 | +# only way to check that the window gets past the connect screen is to sit in | ||
| 437 | +# front of it — which is not a check, and is how a layout error on the chats | ||
| 438 | +# screen went unnoticed while the connect screen looked fine. | ||
| 439 | + | ||
| 440 | +var autoconnectDone = false | ||
| 441 | + | ||
| 442 | +proc maybeAutoconnect*() = | ||
| 443 | + if autoconnectDone: return | ||
| 444 | + autoconnectDone = true | ||
| 445 | + let want = getEnv("FRQ_AUTOCONNECT") | ||
| 446 | + if want.len == 0 or want == "0": return | ||
| 447 | + let nick = getEnv("FRQ_NICK") | ||
| 448 | + if nick.len > 0: app.formNick = nick | ||
| 449 | + trace("auto", "FRQ_AUTOCONNECT set — connecting as " & app.formNick) | ||
| 450 | + connectNow() | ||
modified
nim/src/frq_core.nim +1 -0 | @@ -151,6 +151,7 @@ proc frq_conn_event*(): cstring {.exportc, dynlib.} = | ||
| 151 | 151 | # crossing are a tree going out and an event id coming back. |
| 152 | 152 | |
| 153 | 153 | proc currentTree(): string = |
| 154 | + maybeAutoconnect() | |
| 154 | 155 | ## Whichever screen the state says. `drain` first, so the tree Dart gets is |
| 155 | 156 | ## built after every line that had arrived when it asked — that is the whole |
| 156 | 157 | ## of the polling model, and why there is no callback into Dart. |
| @@ -151,6 +151,7 @@ proc frq_conn_event*(): cstring {.exportc, dynlib.} = | |||
| 151 | # crossing are a tree going out and an event id coming back. | 151 | # crossing are a tree going out and an event id coming back. |
| 152 | 152 | ||
| 153 | proc currentTree(): string = | 153 | proc currentTree(): string = |
| 154 | + maybeAutoconnect() | ||
| 154 | ## Whichever screen the state says. `drain` first, so the tree Dart gets is | 155 | ## Whichever screen the state says. `drain` first, so the tree Dart gets is |
| 155 | ## built after every line that had arrived when it asked — that is the whole | 156 | ## built after every line that had arrived when it asked — that is the whole |
| 156 | ## of the polling model, and why there is no callback into Dart. | 157 | ## of the polling model, and why there is no callback into Dart. |