nandi/frqpublic Fork 0
299008f
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.

The chips reach the edge, the button is opaque, the links open

Three things, and the first is a lesson about `Flexible`.

The chips were meant to ride the right edge and stopped 365 points
short. The row was full width and the tree said "right-aligned", so it
looked correct everywhere except on screen. `Flexible`'s flex is 1 — it
competed with the `Spacer` beside it for the free space, took half, used
the seventy points a name needs, and left the rest as a hole at the end
of the row. So there is no spacer now: the name is `Expanded` and drawn
at the left of the box it gets, which makes that box the slack and
carries everything after it to the edge. It still shrinks on a narrow
row, which is what `Flexible` was there for.

Measured, and the test measures: "the chips are N short of the edge",
which fails at 847 with the old arrangement. Saying it in the tree was
what let this ship wrong in the first place.

`↓ Jump to present` floats over the conversation and was transparent, so
the line underneath read through it. It sits on its own opaque ground
now — which is what floating over something means.

And the links in a message were underlined, blue and inert. `textruns`
emits a label and a URL, the renderer only attached a tap where there
was an `onClick`, and there never was one. Opening a URL is the
platform's rather than the core's — a tab here, `xdg-open` there — so it
goes through the host seam rather than back across the FFI as an event
the core could not act on. `_blank` on the web: a reader following a
link out has not asked to leave the conversation, and navigating this
page away would take the socket with it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
nandi committed 2026-09-20T10:46:29-07:00 Browse files
299008f parent: d67d954
modified flutter/lib/nim_renderer.dart +42 -18
@@ -362,7 +362,19 @@ class _NimAppState extends State<NimApp> {
362362 left: 0,
363363 right: 0,
364364 bottom: t.spaceS,
365- child: Align(alignment: Alignment.bottomCenter, child: o),
365+ child: Align(
366+ alignment: Alignment.bottomCenter,
367+ // Opaque, because it floats over the conversation: a
368+ // control with the backlog showing through it is a
369+ // control nobody can read, and "↓ Jump to present" sat
370+ // on top of whatever line happened to be under it.
371+ child: Material(
372+ color: t.bg,
373+ shape: const StadiumBorder(),
374+ clipBehavior: Clip.antiAlias,
375+ child: o,
376+ ),
377+ ),
366378 ),
367379 ],
368380 ));
@@ -485,12 +497,6 @@ class _NimAppState extends State<NimApp> {
485497
486498 case 'spacer':
487499 {
488- // A gap that takes whatever is left, when it says so. That is what
489- // carries a row's last children to its far edge: `align: end` on a
490- // row cannot, because a Row with no slack has nothing to align.
491- if (n.prop('expand', false) && flex) {
492- return const Spacer();
493- }
494500 final s = _d(n.props['size'], t.spaceXxs);
495501 return SizedBox(width: s, height: s);
496502 }
@@ -549,14 +555,22 @@ class _NimAppState extends State<NimApp> {
549555 overflow: TextOverflow.ellipsis,
550556 style: _style(t.textBody, t.onBg)),
551557 );
552- // `Flexible` and not `Expanded`: a name takes the width it needs
553- // and gives the rest back, but on a row too narrow for everything
554- // it is the part that should shrink. A handle is long, and the
555- // time and the chips beside it are not negotiable — so without
556- // this the sender's row overflowed by however much the name was
557- // over, which on a phone was most handles.
558+ // `Expanded`, and the name is drawn at the left of the box it
559+ // gets. That box is the slack: it grows to fill the row, so
560+ // whatever follows the name is carried to the far edge, and it
561+ // shrinks when the row is too narrow for everything, so a long
562+ // handle ellipsises rather than pushing the time and the chips
563+ // off the end.
564+ //
565+ // `Flexible` was tried first and is the trap: its flex is 1, so
566+ // it competed with the `Spacer` beside it for the free space,
567+ // took half, used the 70 points the name needed and left the
568+ // rest as a hole at the end of the row. The chips looked
569+ // right-aligned to nothing in particular, 365 points short of
570+ // the edge.
558571 return (n.prop('expand', false) && flex)
559- ? Flexible(child: plain)
572+ ? Expanded(
573+ child: Align(alignment: Alignment.centerLeft, child: plain))
560574 : plain;
561575 }
562576 if (kind == 'destructive') {
@@ -875,10 +889,20 @@ class _NimAppState extends State<NimApp> {
875889 style: _style(t.textBody, t.accent)
876890 .copyWith(decoration: TextDecoration.underline,
877891 decorationColor: t.accent),
878- recognizer: onClick.isEmpty
879- ? null
880- : (_linkTaps[url] ??= TapGestureRecognizer()
881- ..onTap = () => _send(onClick)),
892+ // A link with no `onClick` opens itself, which is every link in a
893+ // message: `textruns` emits a label and a URL and nothing else, so
894+ // until now they were underlined, blue, and inert. Opening one is
895+ // the platform's job rather than the core's — a browser tab here,
896+ // `xdg-open` there — so it goes through the host rather than back
897+ // across the seam as an event the core could not act on.
898+ recognizer: _linkTaps[url] ??= TapGestureRecognizer()
899+ ..onTap = () {
900+ if (onClick.isNotEmpty) {
901+ _send(onClick);
902+ } else if (url.isNotEmpty) {
903+ host.openUrl(url);
904+ }
905+ },
882906 );
883907 case 'text':
884908 return TextSpan(
@@ -362,7 +362,19 @@ class _NimAppState extends State<NimApp> {
362 left: 0,362 left: 0,
363 right: 0,363 right: 0,
364 bottom: t.spaceS,364 bottom: t.spaceS,
365- child: Align(alignment: Alignment.bottomCenter, child: o),365+ child: Align(
366+ alignment: Alignment.bottomCenter,
367+ // Opaque, because it floats over the conversation: a
368+ // control with the backlog showing through it is a
369+ // control nobody can read, and "↓ Jump to present" sat
370+ // on top of whatever line happened to be under it.
371+ child: Material(
372+ color: t.bg,
373+ shape: const StadiumBorder(),
374+ clipBehavior: Clip.antiAlias,
375+ child: o,
376+ ),
377+ ),
366 ),378 ),
367 ],379 ],
368 ));380 ));
@@ -485,12 +497,6 @@ class _NimAppState extends State<NimApp> {
485 497
486 case 'spacer':498 case 'spacer':
487 {499 {
488- // A gap that takes whatever is left, when it says so. That is what
489- // carries a row's last children to its far edge: `align: end` on a
490- // row cannot, because a Row with no slack has nothing to align.
491- if (n.prop('expand', false) && flex) {
492- return const Spacer();
493- }
494 final s = _d(n.props['size'], t.spaceXxs);500 final s = _d(n.props['size'], t.spaceXxs);
495 return SizedBox(width: s, height: s);501 return SizedBox(width: s, height: s);
496 }502 }
@@ -549,14 +555,22 @@ class _NimAppState extends State<NimApp> {
549 overflow: TextOverflow.ellipsis,555 overflow: TextOverflow.ellipsis,
550 style: _style(t.textBody, t.onBg)),556 style: _style(t.textBody, t.onBg)),
551 );557 );
552- // `Flexible` and not `Expanded`: a name takes the width it needs558+ // `Expanded`, and the name is drawn at the left of the box it
553- // and gives the rest back, but on a row too narrow for everything559+ // gets. That box is the slack: it grows to fill the row, so
554- // it is the part that should shrink. A handle is long, and the560+ // whatever follows the name is carried to the far edge, and it
555- // time and the chips beside it are not negotiable — so without561+ // shrinks when the row is too narrow for everything, so a long
556- // this the sender's row overflowed by however much the name was562+ // handle ellipsises rather than pushing the time and the chips
557- // over, which on a phone was most handles.563+ // off the end.
564+ //
565+ // `Flexible` was tried first and is the trap: its flex is 1, so
566+ // it competed with the `Spacer` beside it for the free space,
567+ // took half, used the 70 points the name needed and left the
568+ // rest as a hole at the end of the row. The chips looked
569+ // right-aligned to nothing in particular, 365 points short of
570+ // the edge.
558 return (n.prop('expand', false) && flex)571 return (n.prop('expand', false) && flex)
559- ? Flexible(child: plain)572+ ? Expanded(
573+ child: Align(alignment: Alignment.centerLeft, child: plain))
560 : plain;574 : plain;
561 }575 }
562 if (kind == 'destructive') {576 if (kind == 'destructive') {
@@ -875,10 +889,20 @@ class _NimAppState extends State<NimApp> {
875 style: _style(t.textBody, t.accent)889 style: _style(t.textBody, t.accent)
876 .copyWith(decoration: TextDecoration.underline,890 .copyWith(decoration: TextDecoration.underline,
877 decorationColor: t.accent),891 decorationColor: t.accent),
878- recognizer: onClick.isEmpty892+ // A link with no `onClick` opens itself, which is every link in a
879- ? null893+ // message: `textruns` emits a label and a URL and nothing else, so
880- : (_linkTaps[url] ??= TapGestureRecognizer()894+ // until now they were underlined, blue, and inert. Opening one is
881- ..onTap = () => _send(onClick)),895+ // the platform's job rather than the core's — a browser tab here,
896+ // `xdg-open` there — so it goes through the host rather than back
897+ // across the seam as an event the core could not act on.
898+ recognizer: _linkTaps[url] ??= TapGestureRecognizer()
899+ ..onTap = () {
900+ if (onClick.isNotEmpty) {
901+ _send(onClick);
902+ } else if (url.isNotEmpty) {
903+ host.openUrl(url);
904+ }
905+ },
882 );906 );
883 case 'text':907 case 'text':
884 return TextSpan(908 return TextSpan(
modified flutter/lib/src/host_io.dart +19 -0
@@ -7,6 +7,7 @@
77 /// here, behind the conditional in `host.dart`.
88 library;
99
10+import 'dart:async';
1011 import 'dart:io';
1112
1213 import 'package:flutter/widgets.dart';
@@ -41,3 +42,21 @@ const List<String> emojiFonts = <String>[
4142 'Apple Color Emoji', // macOS, iOS
4243 'Segoe UI Emoji', // Windows
4344 ];
45+
46+/// Open a URL in whatever the desktop uses for one.
47+///
48+/// `xdg-open` on Linux, which is the only desktop this builds for; the other
49+/// two are named anyway, because the cost of being wrong about them is a
50+/// link that silently does nothing and the cost of saying so is one line.
51+///
52+/// Best effort and deliberately quiet: a machine with no handler for http is
53+/// a machine where a link cannot be opened, and that is not worth an error
54+/// over the conversation.
55+void openUrl(String url) {
56+ final cmd = Platform.isMacOS ? 'open' : (Platform.isWindows ? 'start' : 'xdg-open');
57+ try {
58+ unawaited(Process.run(cmd, [url]));
59+ } catch (_) {
60+ // Nothing to do, and nothing worth saying.
61+ }
62+}
@@ -7,6 +7,7 @@
7 /// here, behind the conditional in `host.dart`.7 /// here, behind the conditional in `host.dart`.
8 library;8 library;
9 9
10+import 'dart:async';
10 import 'dart:io';11 import 'dart:io';
11 12
12 import 'package:flutter/widgets.dart';13 import 'package:flutter/widgets.dart';
@@ -41,3 +42,21 @@ const List<String> emojiFonts = <String>[
41 'Apple Color Emoji', // macOS, iOS42 'Apple Color Emoji', // macOS, iOS
42 'Segoe UI Emoji', // Windows43 'Segoe UI Emoji', // Windows
43 ];44 ];
45+
46+/// Open a URL in whatever the desktop uses for one.
47+///
48+/// `xdg-open` on Linux, which is the only desktop this builds for; the other
49+/// two are named anyway, because the cost of being wrong about them is a
50+/// link that silently does nothing and the cost of saying so is one line.
51+///
52+/// Best effort and deliberately quiet: a machine with no handler for http is
53+/// a machine where a link cannot be opened, and that is not worth an error
54+/// over the conversation.
55+void openUrl(String url) {
56+ final cmd = Platform.isMacOS ? 'open' : (Platform.isWindows ? 'start' : 'xdg-open');
57+ try {
58+ unawaited(Process.run(cmd, [url]));
59+ } catch (_) {
60+ // Nothing to do, and nothing worth saying.
61+ }
62+}
modified flutter/lib/src/host_web.dart +14 -0
@@ -6,6 +6,8 @@
66 /// as a blob URL or not at all, and the path branch is never reached.
77 library;
88
9+import 'dart:js_interop';
10+
911 import 'package:flutter/widgets.dart';
1012
1113 String envOr(String name, String fallback) => fallback;
@@ -45,3 +47,15 @@ Widget networkImage(String url,
4547 /// named family that is missing is a notdef box rather than a search. Every
4648 /// reaction chip drew ▯ until this list was empty.
4749 const List<String> emojiFonts = <String>[];
50+
51+@JS('window.open')
52+external void _windowOpen(JSString url, JSString target);
53+
54+/// Open a URL in another tab.
55+///
56+/// `_blank`, and not this one: a reader following a link out of a
57+/// conversation has not asked to leave the conversation, and a page that
58+/// navigated away would lose the socket and everything in it.
59+void openUrl(String url) {
60+ _windowOpen(url.toJS, '_blank'.toJS);
61+}
@@ -6,6 +6,8 @@
6 /// as a blob URL or not at all, and the path branch is never reached.6 /// as a blob URL or not at all, and the path branch is never reached.
7 library;7 library;
8 8
9+import 'dart:js_interop';
10+
9 import 'package:flutter/widgets.dart';11 import 'package:flutter/widgets.dart';
10 12
11 String envOr(String name, String fallback) => fallback;13 String envOr(String name, String fallback) => fallback;
@@ -45,3 +47,15 @@ Widget networkImage(String url,
45 /// named family that is missing is a notdef box rather than a search. Every47 /// named family that is missing is a notdef box rather than a search. Every
46 /// reaction chip drew ▯ until this list was empty.48 /// reaction chip drew ▯ until this list was empty.
47 const List<String> emojiFonts = <String>[];49 const List<String> emojiFonts = <String>[];
50+
51+@JS('window.open')
52+external void _windowOpen(JSString url, JSString target);
53+
54+/// Open a URL in another tab.
55+///
56+/// `_blank`, and not this one: a reader following a link out of a
57+/// conversation has not asked to leave the conversation, and a page that
58+/// navigated away would lose the socket and everything in it.
59+void openUrl(String url) {
60+ _windowOpen(url.toJS, '_blank'.toJS);
61+}
modified flutter/test/nim_layout_test.dart +23 -0
@@ -357,6 +357,29 @@ void main() {
357357 });
358358 });
359359
360+ group('the sender row', () {
361+ testWidgets('the chips sit against the right edge of the row',
362+ (tester) async {
363+ // They used to stop well short of it. The name was `Flexible`, whose
364+ // flex is 1, so it competed with the `Spacer` beside it for the free
365+ // space and took half — using the seventy points a name needs and
366+ // leaving the rest as a hole at the end of the row. Measured, because
367+ // "right-aligned" was true of the tree and false of the pixels.
368+ core.demoUi();
369+ core.dispatch('window.size', '1280x800');
370+ await layOut(tester, sizes['desktop']!);
371+ await tester.pump(const Duration(milliseconds: 150));
372+
373+ final row = tester.getRect(find.byType(Row).at(1));
374+ final chip = tester.getRect(find.text('🙂').first);
375+ expect(chip.right, greaterThan(row.right - 60),
376+ reason: 'the chips are ${row.right - chip.right} short of the edge');
377+ // And the name is still at the left of it, not centred in the slack.
378+ final name = tester.getRect(find.text('alice').first);
379+ expect(name.left, lessThan(row.left + 60));
380+ });
381+ });
382+
360383 group('identity', () {
361384 // Duplicate keys among siblings are an error Flutter throws at build
362385 // time, so this is mostly a guard on the tree the core emits: every
@@ -357,6 +357,29 @@ void main() {
357 });357 });
358 });358 });
359 359
360+ group('the sender row', () {
361+ testWidgets('the chips sit against the right edge of the row',
362+ (tester) async {
363+ // They used to stop well short of it. The name was `Flexible`, whose
364+ // flex is 1, so it competed with the `Spacer` beside it for the free
365+ // space and took half — using the seventy points a name needs and
366+ // leaving the rest as a hole at the end of the row. Measured, because
367+ // "right-aligned" was true of the tree and false of the pixels.
368+ core.demoUi();
369+ core.dispatch('window.size', '1280x800');
370+ await layOut(tester, sizes['desktop']!);
371+ await tester.pump(const Duration(milliseconds: 150));
372+
373+ final row = tester.getRect(find.byType(Row).at(1));
374+ final chip = tester.getRect(find.text('🙂').first);
375+ expect(chip.right, greaterThan(row.right - 60),
376+ reason: 'the chips are ${row.right - chip.right} short of the edge');
377+ // And the name is still at the left of it, not centred in the slack.
378+ final name = tester.getRect(find.text('alice').first);
379+ expect(name.left, lessThan(row.left + 60));
380+ });
381+ });
382+
360 group('identity', () {383 group('identity', () {
361 // Duplicate keys among siblings are an error Flutter throws at build384 // Duplicate keys among siblings are an error Flutter throws at build
362 // time, so this is mostly a guard on the tree the core emits: every385 // time, so this is mostly a guard on the tree the core emits: every
modified nim/src/frq/screens/chat.nim +7 -7
@@ -154,12 +154,13 @@ proc messageBody(s: State, room: Room, m: Message, highlit: bool): Node =
154154 let open = "profile.open:" & m.frm & ":" & senderActor
155155 # A row where there is room for one, a Wrap where there is not.
156156 #
157- # The chips ride the right edge by way of the `stretch` below, and a
158- # stretch needs slack to take. On a phone there is none: with the name
159- # shrunk to nothing, the face, the time and three chips still ask for
160- # more than 360 points has — which is why this was a Wrap to begin with,
161- # and why on a narrow window it still is, putting the chips on a second
162- # line rather than off the edge.
157+ # The chips ride the right edge because the name takes the slack: it is
158+ # the row's one expanding child, drawn at the left of a box that grows,
159+ # so everything after it is carried to the far edge. On a phone there is
160+ # no slack to take — with the name shrunk to nothing, the face, the time
161+ # and three chips still ask for more than 360 points has — which is why
162+ # this was a Wrap to begin with, and why on a narrow window it still is,
163+ # putting the chips on a second line rather than off the edge.
163164 var row = hbox(%*{"spacing": 6, "wrap": not s.wide},
164165 # From the profile cache rather than the message: a face belongs to a
165166 # person, not to a line they said, and a profile that arrives after
@@ -168,7 +169,6 @@ proc messageBody(s: State, room: Room, m: Message, highlit: bool): Node =
168169 onClick = open),
169170 n("button", %*{"label": m.frm, "kind": "plain", "onClick": open,
170171 "expand": s.wide}))
171- if s.wide: row.children.add stretch()
172172 if m.at > 0:
173173 row.children.add dimLabel(clockTime(m.at))
174174 if m.edited:
@@ -154,12 +154,13 @@ proc messageBody(s: State, room: Room, m: Message, highlit: bool): Node =
154 let open = "profile.open:" & m.frm & ":" & senderActor154 let open = "profile.open:" & m.frm & ":" & senderActor
155 # A row where there is room for one, a Wrap where there is not.155 # A row where there is room for one, a Wrap where there is not.
156 #156 #
157- # The chips ride the right edge by way of the `stretch` below, and a157+ # The chips ride the right edge because the name takes the slack: it is
158- # stretch needs slack to take. On a phone there is none: with the name158+ # the row's one expanding child, drawn at the left of a box that grows,
159- # shrunk to nothing, the face, the time and three chips still ask for159+ # so everything after it is carried to the far edge. On a phone there is
160- # more than 360 points has — which is why this was a Wrap to begin with,160+ # no slack to take — with the name shrunk to nothing, the face, the time
161- # and why on a narrow window it still is, putting the chips on a second161+ # and three chips still ask for more than 360 points has — which is why
162- # line rather than off the edge.162+ # this was a Wrap to begin with, and why on a narrow window it still is,
163+ # putting the chips on a second line rather than off the edge.
163 var row = hbox(%*{"spacing": 6, "wrap": not s.wide},164 var row = hbox(%*{"spacing": 6, "wrap": not s.wide},
164 # From the profile cache rather than the message: a face belongs to a165 # From the profile cache rather than the message: a face belongs to a
165 # person, not to a line they said, and a profile that arrives after166 # person, not to a line they said, and a profile that arrives after
@@ -168,7 +169,6 @@ proc messageBody(s: State, room: Room, m: Message, highlit: bool): Node =
168 onClick = open),169 onClick = open),
169 n("button", %*{"label": m.frm, "kind": "plain", "onClick": open,170 n("button", %*{"label": m.frm, "kind": "plain", "onClick": open,
170 "expand": s.wide}))171 "expand": s.wide}))
171- if s.wide: row.children.add stretch()
172 if m.at > 0:172 if m.at > 0:
173 row.children.add dimLabel(clockTime(m.at))173 row.children.add dimLabel(clockTime(m.at))
174 if m.edited:174 if m.edited:
modified nim/src/frq/ui.nim +0 -5
@@ -102,11 +102,6 @@ func separator*(): Node = n("separator")
102102
103103 func spacer*(size: int): Node = n("spacer", %*{"size": size})
104104
105-func stretch*(): Node = n("spacer", %*{"expand": true})
106- ## A gap that takes whatever the row has left, so what follows it sits
107- ## against the far edge. Only inside a row that does not wrap — a `Wrap`
108- ## packs from the left and has no slack to give away.
109-
110105 func paragraph*(children: varargs[Node]): Node =
111106 ## Prose with links in it, wrapping as text rather than as boxes.
112107 ##
@@ -102,11 +102,6 @@ func separator*(): Node = n("separator")
102 102
103 func spacer*(size: int): Node = n("spacer", %*{"size": size})103 func spacer*(size: int): Node = n("spacer", %*{"size": size})
104 104
105-func stretch*(): Node = n("spacer", %*{"expand": true})
106- ## A gap that takes whatever the row has left, so what follows it sits
107- ## against the far edge. Only inside a row that does not wrap — a `Wrap`
108- ## packs from the left and has no slack to give away.
109-
110 func paragraph*(children: varargs[Node]): Node =105 func paragraph*(children: varargs[Node]): Node =
111 ## Prose with links in it, wrapping as text rather than as boxes.106 ## Prose with links in it, wrapping as text rather than as boxes.
112 ##107 ##
modified nim/tests/tscreens.nim +13 -7
@@ -333,21 +333,27 @@ suite "the sender's row":
333333
334334 test "on a wide window the chips are carried to the right edge":
335335 # `align: end` on the chips never did anything: the row was a Wrap, which
336- # packs from the left and has no slack to align with. A stretch in a row
337- # that does not wrap is what moves them.
336+ # packs from the left and has no slack to align with. What moves them is
337+ # the name expanding — it is drawn at the left of a box that grows, so
338+ # everything after it ends up against the far edge.
338339 s.windowWidth = wideWidth
339340 let t = cht.chatScreen(s, true)
340- check t.find("spacer").anyIt(it.props{"expand"}.getBool())
341- check t.find("hbox").anyIt(not it.props{"wrap"}.getBool() and
342- it.children.anyIt(it.tag == "avatar"))
341+ let senderRows = t.find("hbox").filterIt(
342+ it.children.anyIt(it.tag == "avatar"))
343+ check senderRows.len == 1
344+ check not senderRows[0].props{"wrap"}.getBool()
345+ check senderRows[0].children.anyIt(
346+ it.tag == "button" and it.props{"expand"}.getBool())
343347
344348 test "on a narrow one it wraps instead, and nothing is pushed off":
345349 # With the name shrunk to nothing the face, the time and three chips
346- # still ask for more than a phone has, so there the row wraps as before.
350+ # still ask for more than a phone has, so there the row wraps as before —
351+ # and nothing expands, because a Wrap has no slack to give.
347352 s.windowWidth = wideWidth - 1 # one pane at a time
348353 let t = cht.chatScreen(s, true)
349- check not t.find("spacer").anyIt(it.props{"expand"}.getBool())
350354 let senderRows = t.find("hbox").filterIt(
351355 it.children.anyIt(it.tag == "avatar"))
352356 check senderRows.len == 1
353357 check senderRows[0].props{"wrap"}.getBool()
358+ check not senderRows[0].children.anyIt(
359+ it.tag == "button" and it.props{"expand"}.getBool())
@@ -333,21 +333,27 @@ suite "the sender's row":
333 333
334 test "on a wide window the chips are carried to the right edge":334 test "on a wide window the chips are carried to the right edge":
335 # `align: end` on the chips never did anything: the row was a Wrap, which335 # `align: end` on the chips never did anything: the row was a Wrap, which
336- # packs from the left and has no slack to align with. A stretch in a row336+ # packs from the left and has no slack to align with. What moves them is
337- # that does not wrap is what moves them.337+ # the name expanding — it is drawn at the left of a box that grows, so
338+ # everything after it ends up against the far edge.
338 s.windowWidth = wideWidth339 s.windowWidth = wideWidth
339 let t = cht.chatScreen(s, true)340 let t = cht.chatScreen(s, true)
340- check t.find("spacer").anyIt(it.props{"expand"}.getBool())341+ let senderRows = t.find("hbox").filterIt(
341- check t.find("hbox").anyIt(not it.props{"wrap"}.getBool() and342+ it.children.anyIt(it.tag == "avatar"))
342- it.children.anyIt(it.tag == "avatar"))343+ check senderRows.len == 1
344+ check not senderRows[0].props{"wrap"}.getBool()
345+ check senderRows[0].children.anyIt(
346+ it.tag == "button" and it.props{"expand"}.getBool())
343 347
344 test "on a narrow one it wraps instead, and nothing is pushed off":348 test "on a narrow one it wraps instead, and nothing is pushed off":
345 # With the name shrunk to nothing the face, the time and three chips349 # With the name shrunk to nothing the face, the time and three chips
346- # still ask for more than a phone has, so there the row wraps as before.350+ # still ask for more than a phone has, so there the row wraps as before —
351+ # and nothing expands, because a Wrap has no slack to give.
347 s.windowWidth = wideWidth - 1 # one pane at a time352 s.windowWidth = wideWidth - 1 # one pane at a time
348 let t = cht.chatScreen(s, true)353 let t = cht.chatScreen(s, true)
349- check not t.find("spacer").anyIt(it.props{"expand"}.getBool())
350 let senderRows = t.find("hbox").filterIt(354 let senderRows = t.find("hbox").filterIt(
351 it.children.anyIt(it.tag == "avatar"))355 it.children.anyIt(it.tag == "avatar"))
352 check senderRows.len == 1356 check senderRows.len == 1
353 check senderRows[0].props{"wrap"}.getBool()357 check senderRows[0].props{"wrap"}.getBool()
358+ check not senderRows[0].children.anyIt(
359+ it.tag == "button" and it.props{"expand"}.getBool())