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>
df9fc99 parent: 6089101 modified
flutter/lib/nim_renderer.dart +46 -1 | @@ -522,9 +522,18 @@ class _NimAppState extends State<NimApp> { | ||
| 522 | 522 | ); |
| 523 | 523 | |
| 524 | 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 | 529 | style: _style(t.textTitle3, t.onBg) |
| 527 | 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 | 538 | case 'title-2': |
| 530 | 539 | return Padding( |
| @@ -648,6 +657,42 @@ class _NimAppState extends State<NimApp> { | ||
| 648 | 657 | child: Align(alignment: Alignment.centerLeft, child: plain)) |
| 649 | 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 | 696 | if (kind == 'destructive') { |
| 652 | 697 | return FilledButton( |
| 653 | 698 | 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() { | ||
| 370 | 370 | await layOut(tester, sizes['desktop']!); |
| 371 | 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 | 380 | final chip = tester.getRect(find.text('🙂').first); |
| 375 | 381 | expect(chip.right, greaterThan(row.right - 60), |
| 376 | 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 | } |
| @@ -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 = | ||
| 386 | 386 | # same thing, one of them cramped. |
| 387 | 387 | 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, | |
| 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}) | |
| 394 | 421 | |
| 395 | 422 | # Each control that comes and goes is in a wrapper of its own, so a child |
| 396 | 423 | # appearing does not renumber the row for the renderer. |
| 397 | 424 | var back = vbox(%*{"key": "back"}) |
| 398 | 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 | 431 | headRow.children.add back |
| 401 | 432 | |
| 402 | 433 | var fold = vbox(%*{"key": "fold"}) |
| @@ -404,24 +435,34 @@ proc chatScreen*(s: State, connected: bool): Node = | ||
| 404 | 435 | # One label, lit while the list is up. It used to drop to a bare "☰" with |
| 405 | 436 | # the list showing, which made the switch two different-looking controls in |
| 406 | 437 | # 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) | |
| 409 | 439 | 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 | 447 | var people = vbox(%*{"key": "people"}) |
| 414 | 448 | 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) | |
| 417 | 451 | headRow.children.add people |
| 418 | 452 | |
| 419 | 453 | # Not in a conditional wrapper: the overview is about every room rather than |
| 420 | 454 | # 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)) | |
| 425 | 466 | |
| 426 | 467 | # The backlog. Not a page — a page scrolls everything, which would carry the |
| 427 | 468 | # compose bar off the bottom with the messages. |
| @@ -567,6 +608,7 @@ proc chatScreen*(s: State, connected: bool): Node = | ||
| 567 | 608 | |
| 568 | 609 | vbox(%*{"spacing": 8, "margin": 12, "expand": true}, |
| 569 | 610 | headRow, |
| 611 | + heading, | |
| 570 | 612 | errorNote(s), |
| 571 | 613 | # `expand` on the row itself: it is the thing that takes the column's |
| 572 | 614 | # 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.hideChatList | 387 | 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 a | 390 | + # |
| 391 | - # Row every one of them is a flex child sharing what there is — so Overview | 391 | + # 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 child | 422 | # 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 back | 431 | 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 "☰" with | 435 | # 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 in | 436 | # 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 fold | 439 | 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 people | 451 | headRow.children.add people |
| 418 | 452 | ||
| 419 | # Not in a conditional wrapper: the overview is about every room rather than | 453 | # 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 the | 467 | # 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's | 613 | # `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 this | 614 | # 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 = | ||
| 61 | 61 | |
| 62 | 62 | func label*(text: string): Node = n("label", %*{"label": text}) |
| 63 | 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 | 71 | func title2*(text: string): Node = n("title-2", %*{"label": text}) |
| 66 | 72 | func spinner*(): Node = n("spinner") |
| 67 | 73 | |
| @@ -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": | ||
| 159 | 159 | check "Picture attached" in cs.chatScreen(s, true).labels("dim-label") |
| 160 | 160 | |
| 161 | 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 | 163 | s.windowWidth = 1200 |
| 164 | 164 | let wide = cs.chatScreen(s, true) |
| 165 | - check "← Chats" notin wide.labels("button") | |
| 165 | + check "←" notin wide.labels("button") | |
| 166 | 166 | check "☰ Chats" in wide.labels("button") |
| 167 | 167 | |
| 168 | 168 | 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 = 1200 | 163 | 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": | ||
| 309 | 309 | s.windowWidth = wideWidth - 1 |
| 310 | 310 | let t = cht.chatScreen(s, true) |
| 311 | 311 | check "Discover" notin t.labels("button") |
| 312 | - check "← Chats" in t.labels("button") | |
| 312 | + check "←" in t.labels("button") | |
| 313 | 313 | |
| 314 | 314 | test "a room in the strip opens it, and the current one is lit": |
| 315 | 315 | s.hideChatList = false |
| @@ -309,7 +309,7 @@ suite "the chat screen's panes": | |||
| 309 | s.windowWidth = wideWidth - 1 | 309 | 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 = false | 315 | s.hideChatList = false |