nandi/frqpublic Fork
⑂ df9fc99
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.

Download ZIP

A room header that fits a phone, and a name with room to be read

Overview sat alone on a second line. The row wrapped, and wrapping was
the wrong thing to bend.

The measurements settle it. A chip here is 160.8 points, of which 48
is Material's own padding, so the three of them are 385 before the
room's name is drawn on a screen 360 wide. No amount of shrinking the
name fits that: it would be left about twenty points, which is not a
name, it is a hint. The padding, not the wording, is what there is no
room for.

So a `compact` button -- every label kept, the padding spent on them
instead -- and the split made on purpose rather than by overflow. The
controls are few, fixed and related, and go on one line together. The
name is the one element with no upper bound, and gets the width to
itself, which is also the answer to a long channel name: it has the
room, and ellipsises rather than shoving a control off the end. Two
lines either way, and this is the pair worth having.

`← Chats` becomes `←` on a narrow window. It measures 111 points and
the row is 35 over with it; what the word costs is People and Overview
side by side, which is what the row is for.

Not a horizontal scroll: a row you must drag to find a button in is a
worse answer than a name that trails off.

Three layout tests, because "on one line" is a fact about pixels and
the tree said hbox all along. They fail on the old header. The sender
row's test now finds its Row by ancestry -- `Row.at(1)` was a fact
about everything above it, and moved the day the header stopped being
a Wrap.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
nandi committed 2026-09-20T14:27:41-07:00 Browse files
df9fc99 parent: 6089101
modified flutter/lib/nim_renderer.dart +46 -1
@@ -522,9 +522,18 @@ class _NimAppState extends State<NimApp> {
522522 );
523523
524524 case 'title':
525- return Text(n.prop('label', ''),
525+ final shrinks = n.prop('shrink', false);
526+ final heading = Text(n.prop('label', ''),
527+ maxLines: shrinks ? 1 : null,
528+ overflow: shrinks ? TextOverflow.ellipsis : TextOverflow.clip,
526529 style: _style(t.textTitle3, t.onBg)
527530 .copyWith(fontWeight: FontWeight.bold));
531+ // `Flexible` and deliberately not `Expanded`: a room's name should
532+ // take the width it needs and no more, so the controls stay gathered
533+ // beside it instead of being flung to the far edge. What it gives up
534+ // is only the width it does not have -- a name longer than the row
535+ // ellipsises, where before it pushed a control onto a second line.
536+ return shrinks && flex ? Flexible(child: heading) : heading;
528537
529538 case 'title-2':
530539 return Padding(
@@ -648,6 +657,42 @@ class _NimAppState extends State<NimApp> {
648657 child: Align(alignment: Alignment.centerLeft, child: plain))
649658 : plain;
650659 }
660+ // A control in a row that has more of them than a phone is wide.
661+ //
662+ // Measured: an ordinary chip here is 160.8 points, of which 48 is
663+ // Material's own horizontal padding and the rest a short word.
664+ // Three of them come to 385 before a room's name is drawn, on a
665+ // screen 360 wide -- so the padding, not the wording, is what
666+ // there is no room for. This keeps every label and spends the
667+ // space on them instead.
668+ if (kind == 'compact') {
669+ return OutlinedButton(
670+ style: OutlinedButton.styleFrom(
671+ padding: const EdgeInsets.symmetric(horizontal: 10),
672+ minimumSize: const Size(0, 36),
673+ tapTargetSize: MaterialTapTargetSize.shrinkWrap,
674+ visualDensity: VisualDensity.compact,
675+ textStyle: _style(t.textBody, t.onBg)
676+ .copyWith(fontSize: 13, fontWeight: FontWeight.w600),
677+ ),
678+ onPressed: () => _send(onClick),
679+ child: label,
680+ );
681+ }
682+ if (kind == 'compact-on') {
683+ return FilledButton(
684+ style: FilledButton.styleFrom(
685+ padding: const EdgeInsets.symmetric(horizontal: 10),
686+ minimumSize: const Size(0, 36),
687+ tapTargetSize: MaterialTapTargetSize.shrinkWrap,
688+ visualDensity: VisualDensity.compact,
689+ textStyle: _style(t.textBody, t.onBg)
690+ .copyWith(fontSize: 13, fontWeight: FontWeight.w600),
691+ ),
692+ onPressed: () => _send(onClick),
693+ child: label,
694+ );
695+ }
651696 if (kind == 'destructive') {
652697 return FilledButton(
653698 style: FilledButton.styleFrom(
@@ -522,9 +522,18 @@ class _NimAppState extends State<NimApp> {
522 );522 );
523 523
524 case 'title':524 case 'title':
525- return Text(n.prop('label', ''),525+ final shrinks = n.prop('shrink', false);
526+ final heading = Text(n.prop('label', ''),
527+ maxLines: shrinks ? 1 : null,
528+ overflow: shrinks ? TextOverflow.ellipsis : TextOverflow.clip,
526 style: _style(t.textTitle3, t.onBg)529 style: _style(t.textTitle3, t.onBg)
527 .copyWith(fontWeight: FontWeight.bold));530 .copyWith(fontWeight: FontWeight.bold));
531+ // `Flexible` and deliberately not `Expanded`: a room's name should
532+ // take the width it needs and no more, so the controls stay gathered
533+ // beside it instead of being flung to the far edge. What it gives up
534+ // is only the width it does not have -- a name longer than the row
535+ // ellipsises, where before it pushed a control onto a second line.
536+ return shrinks && flex ? Flexible(child: heading) : heading;
528 537
529 case 'title-2':538 case 'title-2':
530 return Padding(539 return Padding(
@@ -648,6 +657,42 @@ class _NimAppState extends State<NimApp> {
648 child: Align(alignment: Alignment.centerLeft, child: plain))657 child: Align(alignment: Alignment.centerLeft, child: plain))
649 : plain;658 : plain;
650 }659 }
660+ // A control in a row that has more of them than a phone is wide.
661+ //
662+ // Measured: an ordinary chip here is 160.8 points, of which 48 is
663+ // Material's own horizontal padding and the rest a short word.
664+ // Three of them come to 385 before a room's name is drawn, on a
665+ // screen 360 wide -- so the padding, not the wording, is what
666+ // there is no room for. This keeps every label and spends the
667+ // space on them instead.
668+ if (kind == 'compact') {
669+ return OutlinedButton(
670+ style: OutlinedButton.styleFrom(
671+ padding: const EdgeInsets.symmetric(horizontal: 10),
672+ minimumSize: const Size(0, 36),
673+ tapTargetSize: MaterialTapTargetSize.shrinkWrap,
674+ visualDensity: VisualDensity.compact,
675+ textStyle: _style(t.textBody, t.onBg)
676+ .copyWith(fontSize: 13, fontWeight: FontWeight.w600),
677+ ),
678+ onPressed: () => _send(onClick),
679+ child: label,
680+ );
681+ }
682+ if (kind == 'compact-on') {
683+ return FilledButton(
684+ style: FilledButton.styleFrom(
685+ padding: const EdgeInsets.symmetric(horizontal: 10),
686+ minimumSize: const Size(0, 36),
687+ tapTargetSize: MaterialTapTargetSize.shrinkWrap,
688+ visualDensity: VisualDensity.compact,
689+ textStyle: _style(t.textBody, t.onBg)
690+ .copyWith(fontSize: 13, fontWeight: FontWeight.w600),
691+ ),
692+ onPressed: () => _send(onClick),
693+ child: label,
694+ );
695+ }
651 if (kind == 'destructive') {696 if (kind == 'destructive') {
652 return FilledButton(697 return FilledButton(
653 style: FilledButton.styleFrom(698 style: FilledButton.styleFrom(
modified flutter/test/nim_layout_test.dart +62 -1
@@ -370,7 +370,13 @@ void main() {
370370 await layOut(tester, sizes['desktop']!);
371371 await tester.pump(const Duration(milliseconds: 150));
372372
373- final row = tester.getRect(find.byType(Row).at(1));
373+ // The row the chip is actually in, found by ancestry rather than by
374+ // index. `Row.at(1)` meant "the second Row in the tree", which is a
375+ // fact about everything above this one -- it moved the day the header
376+ // stopped being a Wrap.
377+ final row = tester.getRect(find
378+ .ancestor(of: find.text('🙂').first, matching: find.byType(Row))
379+ .first);
374380 final chip = tester.getRect(find.text('🙂').first);
375381 expect(chip.right, greaterThan(row.right - 60),
376382 reason: 'the chips are ${row.right - chip.right} short of the edge');
@@ -472,4 +478,59 @@ void main() {
472478 }
473479 });
474480 });
481+
482+ group('the room header', () {
483+ // "On one line" is a fact about pixels, not about the tree: the tree said
484+ // hbox all along and Overview was still on a second row.
485+ Future<void> headerFits(WidgetTester tester, Size size, String what) async {
486+ core.demoUi();
487+ await layOut(tester, size);
488+ final back = tester.getRect(find.text('←').first);
489+ final people = tester.getRect(find.textContaining('People').first);
490+ final overview = tester.getRect(find.text('Overview').first);
491+ expect(people.top, back.top,
492+ reason: '$what: People is on another line from the back arrow');
493+ expect(overview.top, back.top,
494+ reason: '$what: Overview wrapped to its own line');
495+ expect(overview.right, lessThanOrEqualTo(size.width),
496+ reason: '$what: Overview runs ${overview.right - size.width} '
497+ 'past the right edge');
498+ }
499+
500+ testWidgets('fits on one line on a phone', (tester) async {
501+ await headerFits(tester, sizes['phone']!, 'phone');
502+ expectLaidOut(tester, 'the phone header');
503+ });
504+
505+ testWidgets('and on a cramped one it wraps rather than overflowing',
506+ (tester) async {
507+ // 300 points is narrower than any phone and the three chips do not fit
508+ // it. What matters there is that the row gives way by wrapping -- the
509+ // safety net -- instead of painting a control off the edge where it
510+ // cannot be pressed.
511+ core.demoUi();
512+ await layOut(tester, sizes['cramped']!);
513+ final overview = tester.getRect(find.text('Overview').first);
514+ expect(overview.right,
515+ lessThanOrEqualTo(sizes['cramped']!.width),
516+ reason: 'Overview runs past the right edge of a cramped window');
517+ expectLaidOut(tester, 'the cramped header');
518+ });
519+
520+ testWidgets('a long room name ellipsises rather than pushing a control off',
521+ (tester) async {
522+ // The case that made wrapping look necessary. The name is the only
523+ // element with no upper bound, so it is the one that yields -- and a
524+ // reader still knows the room from its first few characters, where a
525+ // control shoved onto a second line costs a row of the backlog.
526+ core.demoUi();
527+ core.dispatch('room.open:#a-very-long-channel-name-indeed-for-testing');
528+ await layOut(tester, sizes['phone']!);
529+ final overview = tester.getRect(find.text('Overview').first);
530+ final back = tester.getRect(find.text('←').first);
531+ expect(overview.top, back.top, reason: 'a long name pushed Overview off');
532+ expect(overview.right, lessThanOrEqualTo(sizes['phone']!.width));
533+ expectLaidOut(tester, 'a long room name');
534+ });
535+ });
475536 }
@@ -370,7 +370,13 @@ void main() {
370 await layOut(tester, sizes['desktop']!);370 await layOut(tester, sizes['desktop']!);
371 await tester.pump(const Duration(milliseconds: 150));371 await tester.pump(const Duration(milliseconds: 150));
372 372
373- final row = tester.getRect(find.byType(Row).at(1));373+ // The row the chip is actually in, found by ancestry rather than by
374+ // index. `Row.at(1)` meant "the second Row in the tree", which is a
375+ // fact about everything above this one -- it moved the day the header
376+ // stopped being a Wrap.
377+ final row = tester.getRect(find
378+ .ancestor(of: find.text('🙂').first, matching: find.byType(Row))
379+ .first);
374 final chip = tester.getRect(find.text('🙂').first);380 final chip = tester.getRect(find.text('🙂').first);
375 expect(chip.right, greaterThan(row.right - 60),381 expect(chip.right, greaterThan(row.right - 60),
376 reason: 'the chips are ${row.right - chip.right} short of the edge');382 reason: 'the chips are ${row.right - chip.right} short of the edge');
@@ -472,4 +478,59 @@ void main() {
472 }478 }
473 });479 });
474 });480 });
481+
482+ group('the room header', () {
483+ // "On one line" is a fact about pixels, not about the tree: the tree said
484+ // hbox all along and Overview was still on a second row.
485+ Future<void> headerFits(WidgetTester tester, Size size, String what) async {
486+ core.demoUi();
487+ await layOut(tester, size);
488+ final back = tester.getRect(find.text('←').first);
489+ final people = tester.getRect(find.textContaining('People').first);
490+ final overview = tester.getRect(find.text('Overview').first);
491+ expect(people.top, back.top,
492+ reason: '$what: People is on another line from the back arrow');
493+ expect(overview.top, back.top,
494+ reason: '$what: Overview wrapped to its own line');
495+ expect(overview.right, lessThanOrEqualTo(size.width),
496+ reason: '$what: Overview runs ${overview.right - size.width} '
497+ 'past the right edge');
498+ }
499+
500+ testWidgets('fits on one line on a phone', (tester) async {
501+ await headerFits(tester, sizes['phone']!, 'phone');
502+ expectLaidOut(tester, 'the phone header');
503+ });
504+
505+ testWidgets('and on a cramped one it wraps rather than overflowing',
506+ (tester) async {
507+ // 300 points is narrower than any phone and the three chips do not fit
508+ // it. What matters there is that the row gives way by wrapping -- the
509+ // safety net -- instead of painting a control off the edge where it
510+ // cannot be pressed.
511+ core.demoUi();
512+ await layOut(tester, sizes['cramped']!);
513+ final overview = tester.getRect(find.text('Overview').first);
514+ expect(overview.right,
515+ lessThanOrEqualTo(sizes['cramped']!.width),
516+ reason: 'Overview runs past the right edge of a cramped window');
517+ expectLaidOut(tester, 'the cramped header');
518+ });
519+
520+ testWidgets('a long room name ellipsises rather than pushing a control off',
521+ (tester) async {
522+ // The case that made wrapping look necessary. The name is the only
523+ // element with no upper bound, so it is the one that yields -- and a
524+ // reader still knows the room from its first few characters, where a
525+ // control shoved onto a second line costs a row of the backlog.
526+ core.demoUi();
527+ core.dispatch('room.open:#a-very-long-channel-name-indeed-for-testing');
528+ await layOut(tester, sizes['phone']!);
529+ final overview = tester.getRect(find.text('Overview').first);
530+ final back = tester.getRect(find.text('←').first);
531+ expect(overview.top, back.top, reason: 'a long name pushed Overview off');
532+ expect(overview.right, lessThanOrEqualTo(sizes['phone']!.width));
533+ expectLaidOut(tester, 'a long room name');
534+ });
535+ });
475 }536 }
modified nim/src/frq/screens/chat.nim +57 -15
@@ -386,17 +386,48 @@ proc chatScreen*(s: State, connected: bool): Node =
386386 # same thing, one of them cramped.
387387 let showChatList = s.wide and not s.hideChatList
388388
389- # Wrapping, because on a phone this row asks for more than there is: ← Chats,
390- # the room's name, People and Overview do not fit across 360 points, and in a
391- # Row every one of them is a flex child sharing what there is — so Overview
392- # was allotted a quarter of the width and painted itself "Overvi…".
393- var headRow = hbox(%*{"spacing": 8, "wrap": true})
389+ # What shares a line, and what gets one to itself.
390+ #
391+ # Every control used to sit beside the room's name and the row wrapped,
392+ # which put Overview alone on a second line looking like an orphan. The
393+ # measurements say why nothing subtler works: a chip here is 160.8 points,
394+ # of which 48 is Material's padding, so the three of them are 385 before
395+ # the name is drawn on a screen 360 wide. No amount of shrinking the name
396+ # fits that; at 360 it would be left about twenty points, which is not a
397+ # name, it is a hint.
398+ #
399+ # So the split is made deliberately instead of by overflow. The controls
400+ # are few, fixed and related, and they go together on one line in their
401+ # compact form. The name is the one element with no upper bound, and it
402+ # gets the width to itself -- which is also the answer to a long channel
403+ # name, where before it squeezed the controls and now it simply has the
404+ # room. Two lines either way, and this is the pair worth having.
405+ #
406+ # Not a horizontal scroll: a row you must drag to find a button in is a
407+ # worse answer than a name that trails off.
408+ let compact = not s.wide
409+ let chip = proc(text, event: string, on: bool): Node =
410+ if compact:
411+ button(text, event, if on: "compact-on" else: "compact")
412+ else:
413+ button(text, event, if on: "primary" else: "default")
414+
415+ # `wrap` stays on where the name is not in this row: nothing in it is
416+ # flexible then, so wrapping is a safety net that costs nothing and never
417+ # fires at a real phone's width -- the three compact chips come to 352 of
418+ # 360. On a wide window the name is here and is `Flexible`, which a Wrap
419+ # cannot hold, so the row is a Row.
420+ var headRow = hbox(%*{"spacing": 8, "wrap": compact})
394421
395422 # Each control that comes and goes is in a wrapper of its own, so a child
396423 # appearing does not renumber the row for the renderer.
397424 var back = vbox(%*{"key": "back"})
398425 if not s.wide:
399- back.children.add button("← Chats", "screen.chats")
426+ # The arrow without the word. "← Chats" measures 111 points of the 360
427+ # there are, and the row is 35 over with it; what dropping it buys is
428+ # People and Overview side by side, which is what this row is for. A
429+ # lone back arrow at the top left is not a thing anybody has to learn.
430+ back.children.add chip("←", "screen.chats", false)
400431 headRow.children.add back
401432
402433 var fold = vbox(%*{"key": "fold"})
@@ -404,24 +435,34 @@ proc chatScreen*(s: State, connected: bool): Node =
404435 # One label, lit while the list is up. It used to drop to a bare "☰" with
405436 # the list showing, which made the switch two different-looking controls in
406437 # the same slot and left the reader guessing which state they were in.
407- fold.children.add button("☰ Chats", "chat-list.toggle",
408- if not s.hideChatList: "primary" else: "default")
438+ fold.children.add chip("☰ Chats", "chat-list.toggle", not s.hideChatList)
409439 headRow.children.add fold
410440
411- headRow.children.add title(name)
441+ # On a wide window the name still rides with the controls -- there is room
442+ # for it there, and a heading on a line of its own above four chips would
443+ # be a lot of empty space.
444+ if not compact:
445+ headRow.children.add title(name, shrink = true)
412446
413447 var people = vbox(%*{"key": "people"})
414448 if isChannel:
415- people.children.add button("People " & $room.users.len, "users.toggle",
416- if s.showUsers: "primary" else: "default")
449+ people.children.add chip("People " & $room.users.len, "users.toggle",
450+ s.showUsers)
417451 headRow.children.add people
418452
419453 # Not in a conditional wrapper: the overview is about every room rather than
420454 # this one, so it is offered in a DM and in a channel alike.
421- headRow.children.add n("button",
422- %*{"key": "overview-toggle", "label": "Overview",
423- "kind": (if s.overview: "primary" else: "default"),
424- "onClick": "overview.toggle"})
455+ headRow.children.add chip("Overview", "overview.toggle", s.overview)
456+
457+ # The name, under the controls and across the whole width, on a narrow
458+ # window only.
459+ var heading = vbox(%*{"key": "room-name"})
460+ if compact:
461+ # `wrap: false`, because a shrinking title is `Flexible` and a Wrap
462+ # cannot hold one -- and without the Row there is nothing to shrink
463+ # against, so a long name would run off the edge instead of ellipsising.
464+ heading.children.add hbox(%*{"spacing": 0, "wrap": false},
465+ title(name, shrink = true))
425466
426467 # The backlog. Not a page — a page scrolls everything, which would carry the
427468 # compose bar off the bottom with the messages.
@@ -567,6 +608,7 @@ proc chatScreen*(s: State, connected: bool): Node =
567608
568609 vbox(%*{"spacing": 8, "margin": 12, "expand": true},
569610 headRow,
611+ heading,
570612 errorNote(s),
571613 # `expand` on the row itself: it is the thing that takes the column's
572614 # remaining height. The renderer used to infer that by looking at this
@@ -386,17 +386,48 @@ proc chatScreen*(s: State, connected: bool): Node =
386 # same thing, one of them cramped.386 # same thing, one of them cramped.
387 let showChatList = s.wide and not s.hideChatList387 let showChatList = s.wide and not s.hideChatList
388 388
389- # Wrapping, because on a phone this row asks for more than there is: ← Chats,389+ # What shares a line, and what gets one to itself.
390- # the room's name, People and Overview do not fit across 360 points, and in a390+ #
391- # Row every one of them is a flex child sharing what there is — so Overview391+ # Every control used to sit beside the room's name and the row wrapped,
392- # was allotted a quarter of the width and painted itself "Overvi…".392+ # which put Overview alone on a second line looking like an orphan. The
393- var headRow = hbox(%*{"spacing": 8, "wrap": true})393+ # measurements say why nothing subtler works: a chip here is 160.8 points,
394+ # of which 48 is Material's padding, so the three of them are 385 before
395+ # the name is drawn on a screen 360 wide. No amount of shrinking the name
396+ # fits that; at 360 it would be left about twenty points, which is not a
397+ # name, it is a hint.
398+ #
399+ # So the split is made deliberately instead of by overflow. The controls
400+ # are few, fixed and related, and they go together on one line in their
401+ # compact form. The name is the one element with no upper bound, and it
402+ # gets the width to itself -- which is also the answer to a long channel
403+ # name, where before it squeezed the controls and now it simply has the
404+ # room. Two lines either way, and this is the pair worth having.
405+ #
406+ # Not a horizontal scroll: a row you must drag to find a button in is a
407+ # worse answer than a name that trails off.
408+ let compact = not s.wide
409+ let chip = proc(text, event: string, on: bool): Node =
410+ if compact:
411+ button(text, event, if on: "compact-on" else: "compact")
412+ else:
413+ button(text, event, if on: "primary" else: "default")
414+
415+ # `wrap` stays on where the name is not in this row: nothing in it is
416+ # flexible then, so wrapping is a safety net that costs nothing and never
417+ # fires at a real phone's width -- the three compact chips come to 352 of
418+ # 360. On a wide window the name is here and is `Flexible`, which a Wrap
419+ # cannot hold, so the row is a Row.
420+ var headRow = hbox(%*{"spacing": 8, "wrap": compact})
394 421
395 # Each control that comes and goes is in a wrapper of its own, so a child422 # Each control that comes and goes is in a wrapper of its own, so a child
396 # appearing does not renumber the row for the renderer.423 # appearing does not renumber the row for the renderer.
397 var back = vbox(%*{"key": "back"})424 var back = vbox(%*{"key": "back"})
398 if not s.wide:425 if not s.wide:
399- back.children.add button("← Chats", "screen.chats")426+ # The arrow without the word. "← Chats" measures 111 points of the 360
427+ # there are, and the row is 35 over with it; what dropping it buys is
428+ # People and Overview side by side, which is what this row is for. A
429+ # lone back arrow at the top left is not a thing anybody has to learn.
430+ back.children.add chip("←", "screen.chats", false)
400 headRow.children.add back431 headRow.children.add back
401 432
402 var fold = vbox(%*{"key": "fold"})433 var fold = vbox(%*{"key": "fold"})
@@ -404,24 +435,34 @@ proc chatScreen*(s: State, connected: bool): Node =
404 # One label, lit while the list is up. It used to drop to a bare "☰" with435 # One label, lit while the list is up. It used to drop to a bare "☰" with
405 # the list showing, which made the switch two different-looking controls in436 # the list showing, which made the switch two different-looking controls in
406 # the same slot and left the reader guessing which state they were in.437 # the same slot and left the reader guessing which state they were in.
407- fold.children.add button("☰ Chats", "chat-list.toggle",438+ fold.children.add chip("☰ Chats", "chat-list.toggle", not s.hideChatList)
408- if not s.hideChatList: "primary" else: "default")
409 headRow.children.add fold439 headRow.children.add fold
410 440
411- headRow.children.add title(name)441+ # On a wide window the name still rides with the controls -- there is room
442+ # for it there, and a heading on a line of its own above four chips would
443+ # be a lot of empty space.
444+ if not compact:
445+ headRow.children.add title(name, shrink = true)
412 446
413 var people = vbox(%*{"key": "people"})447 var people = vbox(%*{"key": "people"})
414 if isChannel:448 if isChannel:
415- people.children.add button("People " & $room.users.len, "users.toggle",449+ people.children.add chip("People " & $room.users.len, "users.toggle",
416- if s.showUsers: "primary" else: "default")450+ s.showUsers)
417 headRow.children.add people451 headRow.children.add people
418 452
419 # Not in a conditional wrapper: the overview is about every room rather than453 # Not in a conditional wrapper: the overview is about every room rather than
420 # this one, so it is offered in a DM and in a channel alike.454 # this one, so it is offered in a DM and in a channel alike.
421- headRow.children.add n("button",455+ headRow.children.add chip("Overview", "overview.toggle", s.overview)
422- %*{"key": "overview-toggle", "label": "Overview",456+
423- "kind": (if s.overview: "primary" else: "default"),457+ # The name, under the controls and across the whole width, on a narrow
424- "onClick": "overview.toggle"})458+ # window only.
459+ var heading = vbox(%*{"key": "room-name"})
460+ if compact:
461+ # `wrap: false`, because a shrinking title is `Flexible` and a Wrap
462+ # cannot hold one -- and without the Row there is nothing to shrink
463+ # against, so a long name would run off the edge instead of ellipsising.
464+ heading.children.add hbox(%*{"spacing": 0, "wrap": false},
465+ title(name, shrink = true))
425 466
426 # The backlog. Not a page — a page scrolls everything, which would carry the467 # The backlog. Not a page — a page scrolls everything, which would carry the
427 # compose bar off the bottom with the messages.468 # compose bar off the bottom with the messages.
@@ -567,6 +608,7 @@ proc chatScreen*(s: State, connected: bool): Node =
567 608
568 vbox(%*{"spacing": 8, "margin": 12, "expand": true},609 vbox(%*{"spacing": 8, "margin": 12, "expand": true},
569 headRow,610 headRow,
611+ heading,
570 errorNote(s),612 errorNote(s),
571 # `expand` on the row itself: it is the thing that takes the column's613 # `expand` on the row itself: it is the thing that takes the column's
572 # remaining height. The renderer used to infer that by looking at this614 # remaining height. The renderer used to infer that by looking at this
modified nim/src/frq/ui.nim +7 -1
@@ -61,7 +61,13 @@ func page*(props: JsonNode, children: varargs[Node]): Node =
6161
6262 func label*(text: string): Node = n("label", %*{"label": text})
6363 func dimLabel*(text: string): Node = n("dim-label", %*{"label": text})
64-func title*(text: string): Node = n("title", %*{"label": text})
64+func title*(text: string, shrink = false): Node =
65+ ## `shrink` is a title that yields: it takes what a row has left over and
66+ ## ellipsises rather than pushing what follows it off the end. Distinct
67+ ## from `expand`, which claims the slack instead of giving it up.
68+ var p = %*{"label": text}
69+ if shrink: p["shrink"] = %true
70+ n("title", p)
6571 func title2*(text: string): Node = n("title-2", %*{"label": text})
6672 func spinner*(): Node = n("spinner")
6773
@@ -61,7 +61,13 @@ func page*(props: JsonNode, children: varargs[Node]): Node =
61 61
62 func label*(text: string): Node = n("label", %*{"label": text})62 func label*(text: string): Node = n("label", %*{"label": text})
63 func dimLabel*(text: string): Node = n("dim-label", %*{"label": text})63 func dimLabel*(text: string): Node = n("dim-label", %*{"label": text})
64-func title*(text: string): Node = n("title", %*{"label": text})64+func title*(text: string, shrink = false): Node =
65+ ## `shrink` is a title that yields: it takes what a row has left over and
66+ ## ellipsises rather than pushing what follows it off the end. Distinct
67+ ## from `expand`, which claims the slack instead of giving it up.
68+ var p = %*{"label": text}
69+ if shrink: p["shrink"] = %true
70+ n("title", p)
65 func title2*(text: string): Node = n("title-2", %*{"label": text})71 func title2*(text: string): Node = n("title-2", %*{"label": text})
66 func spinner*(): Node = n("spinner")72 func spinner*(): Node = n("spinner")
67 73
modified nim/tests/tchat.nim +2 -2
@@ -159,10 +159,10 @@ suite "the chat screen":
159159 check "Picture attached" in cs.chatScreen(s, true).labels("dim-label")
160160
161161 test "Back to chats on a narrow window, fold on a wide one":
162- check "← Chats" in cs.chatScreen(s, true).labels("button")
162+ check "←" in cs.chatScreen(s, true).labels("button")
163163 s.windowWidth = 1200
164164 let wide = cs.chatScreen(s, true)
165- check "← Chats" notin wide.labels("button")
165+ check "←" notin wide.labels("button")
166166 check "☰ Chats" in wide.labels("button")
167167
168168 test "People is offered in a channel and not in a DM":
@@ -159,10 +159,10 @@ suite "the chat screen":
159 check "Picture attached" in cs.chatScreen(s, true).labels("dim-label")159 check "Picture attached" in cs.chatScreen(s, true).labels("dim-label")
160 160
161 test "Back to chats on a narrow window, fold on a wide one":161 test "Back to chats on a narrow window, fold on a wide one":
162- check "← Chats" in cs.chatScreen(s, true).labels("button")162+ check "←" in cs.chatScreen(s, true).labels("button")
163 s.windowWidth = 1200163 s.windowWidth = 1200
164 let wide = cs.chatScreen(s, true)164 let wide = cs.chatScreen(s, true)
165- check "← Chats" notin wide.labels("button")165+ check "←" notin wide.labels("button")
166 check "☰ Chats" in wide.labels("button")166 check "☰ Chats" in wide.labels("button")
167 167
168 test "People is offered in a channel and not in a DM":168 test "People is offered in a channel and not in a DM":
modified nim/tests/tscreens.nim +1 -1
@@ -309,7 +309,7 @@ suite "the chat screen's panes":
309309 s.windowWidth = wideWidth - 1
310310 let t = cht.chatScreen(s, true)
311311 check "Discover" notin t.labels("button")
312- check "← Chats" in t.labels("button")
312+ check "←" in t.labels("button")
313313
314314 test "a room in the strip opens it, and the current one is lit":
315315 s.hideChatList = false
@@ -309,7 +309,7 @@ suite "the chat screen's panes":
309 s.windowWidth = wideWidth - 1309 s.windowWidth = wideWidth - 1
310 let t = cht.chatScreen(s, true)310 let t = cht.chatScreen(s, true)
311 check "Discover" notin t.labels("button")311 check "Discover" notin t.labels("button")
312- check "← Chats" in t.labels("button")312+ check "←" in t.labels("button")
313 313
314 test "a room in the strip opens it, and the current one is lit":314 test "a room in the strip opens it, and the current one is lit":
315 s.hideChatList = false315 s.hideChatList = false