nandi/frqpublic Fork 0
f1e99b4
Commits
Clone
git clone https://git.rickub.com/nandi/frq.git
git clone ssh://git@rickub.com/nandi/frq.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

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>
nandi committed 2026-09-18T23:30:44-07:00 Browse files
f1e99b4 parent: 2e24e64
modified flutter/lib/nim_renderer.dart +50 -15
@@ -134,9 +134,30 @@ class _NimAppState extends State<NimApp> {
134134
135135 // ------------------------------------------------------------------ build
136136
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]) {
139151 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();
140161
141162 switch (n.tag) {
142163 case 'page':
@@ -166,8 +187,11 @@ class _NimAppState extends State<NimApp> {
166187 final w = _d(n.props['widthRequest'], 0);
167188 if (w > 0) col = SizedBox(width: w, child: col);
168189 // `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;
171195 }
172196
173197 case 'hbox':
@@ -179,12 +203,18 @@ class _NimAppState extends State<NimApp> {
179203 final wrapping = n.prop('wrap', true);
180204 final align = n.prop('align', 'center');
181205 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));
182210 return _margins(
183211 n,
184212 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),
188218 children: _spaced(kids, spacing, vertical: false),
189219 ),
190220 );
@@ -475,10 +505,15 @@ class _NimAppState extends State<NimApp> {
475505 onSubmitted: (_) => _send(n.prop('onSubmit', '')),
476506 );
477507 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);
482517 }
483518
484519 case 'scroll':
@@ -493,10 +528,10 @@ class _NimAppState extends State<NimApp> {
493528 body = Scrollbar(child: body);
494529 final h = _d(n.props['height'], 0);
495530 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);
500535 }
501536
502537 /// A panel over the screen rather than a screen of its own.
@@ -134,9 +134,30 @@ class _NimAppState extends State<NimApp> {
134 134
135 // ------------------------------------------------------------------ build135 // ------------------------------------------------------------------ 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 instead189 // `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.end214+ ? 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 here508+ if (w > 0) return SizedBox(width: w, child: field);
479- // it has to be a maximum too: an unconstrained TextField inside a509+ // 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 the531+ // 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 not532+ // 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 @@
1111 ## payload: the alternative is a second serialisation to define and version,
1212 ## for arguments that are always one string.
1313
14-import std/[json, options, sequtils, strutils, tables]
14+import std/[json, options, os, sequtils, strutils, tables]
1515 import std/sets
1616 import frq/[cells, model, rooms, reactions, edits, trace, ircparse, clock,
1717 atproto, handshake]
@@ -426,3 +426,25 @@ proc drain*() =
426426
427427 else:
428428 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/sets15 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.} =
151151 # crossing are a tree going out and an event id coming back.
152152
153153 proc currentTree(): string =
154+ maybeAutoconnect()
154155 ## Whichever screen the state says. `drain` first, so the tree Dart gets is
155156 ## built after every line that had arrived when it asked — that is the whole
156157 ## 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 is155 ## 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 whole156 ## 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.