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>
299008f parent: d67d954 modified
flutter/lib/nim_renderer.dart +42 -18 | @@ -362,7 +362,19 @@ class _NimAppState extends State<NimApp> { | ||
| 362 | 362 | left: 0, |
| 363 | 363 | right: 0, |
| 364 | 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 | 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 | 500 | final s = _d(n.props['size'], t.spaceXxs); |
| 495 | 501 | return SizedBox(width: s, height: s); |
| 496 | 502 | } |
| @@ -549,14 +555,22 @@ class _NimAppState extends State<NimApp> { | ||
| 549 | 555 | overflow: TextOverflow.ellipsis, |
| 550 | 556 | style: _style(t.textBody, t.onBg)), |
| 551 | 557 | ); |
| 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. | |
| 558 | 571 | return (n.prop('expand', false) && flex) |
| 559 | - ? Flexible(child: plain) | |
| 572 | + ? Expanded( | |
| 573 | + child: Align(alignment: Alignment.centerLeft, child: plain)) | |
| 560 | 574 | : plain; |
| 561 | 575 | } |
| 562 | 576 | if (kind == 'destructive') { |
| @@ -875,10 +889,20 @@ class _NimAppState extends State<NimApp> { | ||
| 875 | 889 | style: _style(t.textBody, t.accent) |
| 876 | 890 | .copyWith(decoration: TextDecoration.underline, |
| 877 | 891 | 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 | + }, | |
| 882 | 906 | ); |
| 883 | 907 | case 'text': |
| 884 | 908 | 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 needs | 558 | + // `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 everything | 559 | + // 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 the | 560 | + // whatever follows the name is carried to the far edge, and it |
| 555 | - // time and the chips beside it are not negotiable — so without | 561 | + // shrinks when the row is too narrow for everything, so a long |
| 556 | - // this the sender's row overflowed by however much the name was | 562 | + // 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.isEmpty | 892 | + // A link with no `onClick` opens itself, which is every link in a |
| 879 | - ? null | 893 | + // 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 @@ | ||
| 7 | 7 | /// here, behind the conditional in `host.dart`. |
| 8 | 8 | library; |
| 9 | 9 | |
| 10 | +import 'dart:async'; | |
| 10 | 11 | import 'dart:io'; |
| 11 | 12 | |
| 12 | 13 | import 'package:flutter/widgets.dart'; |
| @@ -41,3 +42,21 @@ const List<String> emojiFonts = <String>[ | ||
| 41 | 42 | 'Apple Color Emoji', // macOS, iOS |
| 42 | 43 | '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 | +} | |
| @@ -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, iOS | 42 | 'Apple Color Emoji', // macOS, iOS |
| 42 | 'Segoe UI Emoji', // Windows | 43 | '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 @@ | ||
| 6 | 6 | /// as a blob URL or not at all, and the path branch is never reached. |
| 7 | 7 | library; |
| 8 | 8 | |
| 9 | +import 'dart:js_interop'; | |
| 10 | + | |
| 9 | 11 | import 'package:flutter/widgets.dart'; |
| 10 | 12 | |
| 11 | 13 | String envOr(String name, String fallback) => fallback; |
| @@ -45,3 +47,15 @@ Widget networkImage(String url, | ||
| 45 | 47 | /// named family that is missing is a notdef box rather than a search. Every |
| 46 | 48 | /// reaction chip drew ▯ until this list was empty. |
| 47 | 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 | +} | |
| @@ -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. Every | 47 | /// 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() { | ||
| 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 | 383 | group('identity', () { |
| 361 | 384 | // Duplicate keys among siblings are an error Flutter throws at build |
| 362 | 385 | // 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 build | 384 | // 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: every | 385 | // 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 = | ||
| 154 | 154 | let open = "profile.open:" & m.frm & ":" & senderActor |
| 155 | 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 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. | |
| 163 | 164 | var row = hbox(%*{"spacing": 6, "wrap": not s.wide}, |
| 164 | 165 | # From the profile cache rather than the message: a face belongs to a |
| 165 | 166 | # 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 | 169 | onClick = open), |
| 169 | 170 | n("button", %*{"label": m.frm, "kind": "plain", "onClick": open, |
| 170 | 171 | "expand": s.wide})) |
| 171 | - if s.wide: row.children.add stretch() | |
| 172 | 172 | if m.at > 0: |
| 173 | 173 | row.children.add dimLabel(clockTime(m.at)) |
| 174 | 174 | 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 & ":" & senderActor | 154 | 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 a | 157 | + # 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 name | 158 | + # 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 for | 159 | + # 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 second | 161 | + # 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 a | 165 | # 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 after | 166 | # 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") | ||
| 102 | 102 | |
| 103 | 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 | 105 | func paragraph*(children: varargs[Node]): Node = |
| 111 | 106 | ## Prose with links in it, wrapping as text rather than as boxes. |
| 112 | 107 | ## |
| @@ -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": | ||
| 333 | 333 | |
| 334 | 334 | test "on a wide window the chips are carried to the right edge": |
| 335 | 335 | # `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. | |
| 338 | 339 | s.windowWidth = wideWidth |
| 339 | 340 | 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()) | |
| 343 | 347 | |
| 344 | 348 | test "on a narrow one it wraps instead, and nothing is pushed off": |
| 345 | 349 | # 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 | 352 | s.windowWidth = wideWidth - 1 # one pane at a time |
| 348 | 353 | let t = cht.chatScreen(s, true) |
| 349 | - check not t.find("spacer").anyIt(it.props{"expand"}.getBool()) | |
| 350 | 354 | let senderRows = t.find("hbox").filterIt( |
| 351 | 355 | it.children.anyIt(it.tag == "avatar")) |
| 352 | 356 | check senderRows.len == 1 |
| 353 | 357 | 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, which | 335 | # `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 | 336 | + # 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 = wideWidth | 339 | 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() and | 342 | + 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 chips | 349 | # 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 time | 352 | 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 == 1 | 356 | 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()) | ||