An overview you can skim on a phone
Each entry was [room, sender, text, →] in one wrapping row. On a phone
that is three lines: the sender, then the text, then the arrow alone
on a row of its own -- a full-height button saying nothing the row had
not already said, and the only part of the entry that could be
pressed. Eight of them filled the screen with furniture.
So the arrow goes and the card takes the press. A `card` with an
`onClick` is both the larger target and the smaller thing to draw, and
it is the pattern the Chats list already uses. What is left is what a
reader skims: who said it and where, then what they said.
The text is clamped to two lines rather than to 48 characters. How
much fits on a line is a fact about the font and the width and a
character count knows neither -- the same 96 characters are two lines
on a phone and five in a widget test, which is why tuning the cap
against the test font was measuring the wrong thing. `summarise` still
bounds what crosses the wire; the renderer bounds what is seen.
Measured, and the measuring found something else: `frq_ui_demo` made
exactly one room, so the overview in every layout test was "Nothing
has happened anywhere else." The test that laid it out was laying out
an empty box. There is a second room now, with a long bot line and a
short one -- awkward on purpose, like the rest of that fixture. An
entry went from 167 points to 91, and 186 with the clamp reverted.
Two older tests were resting on position and are pinned to content
instead: one took `find.text('Open').first` from a list that now has
two rooms in it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>535605d parent: df9fc99 modified
flutter/lib/nim_renderer.dart +24 -2 | @@ -507,7 +507,7 @@ class _NimAppState extends State<NimApp> { | ||
| 507 | 507 | |
| 508 | 508 | // Container::Card in the Clojure: padding 12, fills its width. |
| 509 | 509 | case 'card': |
| 510 | - return Container( | |
| 510 | + final card = Container( | |
| 511 | 511 | width: double.infinity, |
| 512 | 512 | margin: const EdgeInsets.symmetric(vertical: t.spaceXxxs), |
| 513 | 513 | padding: const EdgeInsets.all(t.spaceXs), |
| @@ -520,6 +520,20 @@ class _NimAppState extends State<NimApp> { | ||
| 520 | 520 | children: _spaced(kids, spacing > 0 ? spacing : t.spaceXxs, |
| 521 | 521 | vertical: true)), |
| 522 | 522 | ); |
| 523 | + // A card that is itself the control. Where every line in a list goes | |
| 524 | + // to the same place, a button on each one is a small target beside a | |
| 525 | + // large inert thing -- and on a phone the button is what wraps onto | |
| 526 | + // a row of its own. The whole card takes the press instead. | |
| 527 | + final tap = n.prop('onClick', ''); | |
| 528 | + if (tap.isEmpty) return card; | |
| 529 | + return Material( | |
| 530 | + color: Colors.transparent, | |
| 531 | + child: InkWell( | |
| 532 | + onTap: () => _send(tap), | |
| 533 | + borderRadius: BorderRadius.circular(t.radiusS), | |
| 534 | + child: card, | |
| 535 | + ), | |
| 536 | + ); | |
| 523 | 537 | |
| 524 | 538 | case 'title': |
| 525 | 539 | final shrinks = n.prop('shrink', false); |
| @@ -553,7 +567,15 @@ class _NimAppState extends State<NimApp> { | ||
| 553 | 567 | /// wraps. Kept apart from `label` because a wrapping label in a row |
| 554 | 568 | /// lays out against the row's width rather than the column's. |
| 555 | 569 | case 'text': |
| 556 | - return Text(n.prop('text', ''), style: _style(t.textBody, t.onBg)); | |
| 570 | + // `lines` clamps to that many and ellipsises. A character count | |
| 571 | + // cannot do this job: how much fits on a line is a fact about the | |
| 572 | + // font and the width, and the two disagree — in a widget test the | |
| 573 | + // same 96 characters take five lines where a phone gives them two. | |
| 574 | + final lines = n.prop('lines', 0); | |
| 575 | + return Text(n.prop('text', ''), | |
| 576 | + maxLines: lines > 0 ? lines : null, | |
| 577 | + overflow: lines > 0 ? TextOverflow.ellipsis : TextOverflow.clip, | |
| 578 | + style: _style(t.textBody, t.onBg)); | |
| 557 | 579 | |
| 558 | 580 | case 'link': |
| 559 | 581 | { |
| @@ -507,7 +507,7 @@ class _NimAppState extends State<NimApp> { | |||
| 507 | 507 | ||
| 508 | // Container::Card in the Clojure: padding 12, fills its width. | 508 | // Container::Card in the Clojure: padding 12, fills its width. |
| 509 | case 'card': | 509 | case 'card': |
| 510 | - return Container( | 510 | + final card = Container( |
| 511 | width: double.infinity, | 511 | width: double.infinity, |
| 512 | margin: const EdgeInsets.symmetric(vertical: t.spaceXxxs), | 512 | margin: const EdgeInsets.symmetric(vertical: t.spaceXxxs), |
| 513 | padding: const EdgeInsets.all(t.spaceXs), | 513 | padding: const EdgeInsets.all(t.spaceXs), |
| @@ -520,6 +520,20 @@ class _NimAppState extends State<NimApp> { | |||
| 520 | children: _spaced(kids, spacing > 0 ? spacing : t.spaceXxs, | 520 | children: _spaced(kids, spacing > 0 ? spacing : t.spaceXxs, |
| 521 | vertical: true)), | 521 | vertical: true)), |
| 522 | ); | 522 | ); |
| 523 | + // A card that is itself the control. Where every line in a list goes | ||
| 524 | + // to the same place, a button on each one is a small target beside a | ||
| 525 | + // large inert thing -- and on a phone the button is what wraps onto | ||
| 526 | + // a row of its own. The whole card takes the press instead. | ||
| 527 | + final tap = n.prop('onClick', ''); | ||
| 528 | + if (tap.isEmpty) return card; | ||
| 529 | + return Material( | ||
| 530 | + color: Colors.transparent, | ||
| 531 | + child: InkWell( | ||
| 532 | + onTap: () => _send(tap), | ||
| 533 | + borderRadius: BorderRadius.circular(t.radiusS), | ||
| 534 | + child: card, | ||
| 535 | + ), | ||
| 536 | + ); | ||
| 523 | 537 | ||
| 524 | case 'title': | 538 | case 'title': |
| 525 | final shrinks = n.prop('shrink', false); | 539 | final shrinks = n.prop('shrink', false); |
| @@ -553,7 +567,15 @@ class _NimAppState extends State<NimApp> { | |||
| 553 | /// wraps. Kept apart from `label` because a wrapping label in a row | 567 | /// wraps. Kept apart from `label` because a wrapping label in a row |
| 554 | /// lays out against the row's width rather than the column's. | 568 | /// lays out against the row's width rather than the column's. |
| 555 | case 'text': | 569 | case 'text': |
| 556 | - return Text(n.prop('text', ''), style: _style(t.textBody, t.onBg)); | 570 | + // `lines` clamps to that many and ellipsises. A character count |
| 571 | + // cannot do this job: how much fits on a line is a fact about the | ||
| 572 | + // font and the width, and the two disagree — in a widget test the | ||
| 573 | + // same 96 characters take five lines where a phone gives them two. | ||
| 574 | + final lines = n.prop('lines', 0); | ||
| 575 | + return Text(n.prop('text', ''), | ||
| 576 | + maxLines: lines > 0 ? lines : null, | ||
| 577 | + overflow: lines > 0 ? TextOverflow.ellipsis : TextOverflow.clip, | ||
| 578 | + style: _style(t.textBody, t.onBg)); | ||
| 557 | 579 | ||
| 558 | case 'link': | 580 | case 'link': |
| 559 | { | 581 | { |
modified
flutter/test/nim_layout_test.dart +63 -2 | @@ -232,8 +232,14 @@ void main() { | ||
| 232 | 232 | core.demoUi(); |
| 233 | 233 | core.dispatch('screen.chats'); |
| 234 | 234 | await layOut(tester, sizes['desktop']!); |
| 235 | - // The row's "Open", not its name: the name is a label in this list. | |
| 236 | - await tester.tap(find.text('Open').first); | |
| 235 | + // The row's "Open", not its name: the name is a label in this list — | |
| 236 | + // and the Open belonging to #test, not whichever card is first. There | |
| 237 | + // is more than one room in the demo now, and `.first` was a fact about | |
| 238 | + // the order they happen to come back in. | |
| 239 | + final card = find | |
| 240 | + .ancestor(of: find.text('#test'), matching: find.byType(Container)) | |
| 241 | + .last; | |
| 242 | + await tester.tap(find.descendant(of: card, matching: find.text('Open'))); | |
| 237 | 243 | await tester.pump(); |
| 238 | 244 | expect(find.text('hello there'), findsWidgets, |
| 239 | 245 | reason: 'tapping the room did not open it'); |
| @@ -533,4 +539,59 @@ void main() { | ||
| 533 | 539 | expectLaidOut(tester, 'a long room name'); |
| 534 | 540 | }); |
| 535 | 541 | }); |
| 542 | + | |
| 543 | + group('the overview on a phone', () { | |
| 544 | + testWidgets('an entry is two lines, not three', (tester) async { | |
| 545 | + // The complaint, measured. Each entry was [room, sender, text, →] in a | |
| 546 | + // Wrap: on a phone the arrow wrapped to a row of its own, so an entry | |
| 547 | + // stood about three lines tall and most of that was a button. Counting | |
| 548 | + // rows is unreliable across themes; the height of one entry is not. | |
| 549 | + core.demoUi(); | |
| 550 | + core.dispatch('overview.toggle'); | |
| 551 | + await layOut(tester, sizes['phone']!); | |
| 552 | + | |
| 553 | + // The entry carrying a known line, found by what is in it rather than | |
| 554 | + // by position among every InkWell on the screen. | |
| 555 | + final entry = find | |
| 556 | + .ancestor( | |
| 557 | + of: find.textContaining('sandbox-01'), | |
| 558 | + matching: find.byType(InkWell)) | |
| 559 | + .first; | |
| 560 | + expect(entry, findsOneWidget, reason: 'the entry is not tappable'); | |
| 561 | + | |
| 562 | + final h = tester.getRect(entry).height; | |
| 563 | + expect(h, lessThan(96), | |
| 564 | + reason: 'an overview entry is $h tall — three lines of furniture'); | |
| 565 | + | |
| 566 | + // The long one, which is what the complaint was actually about: a bot | |
| 567 | + // line runs for paragraphs and a summary of it must still be an entry | |
| 568 | + // in a list rather than a page of its own. | |
| 569 | + final long = find | |
| 570 | + .ancestor( | |
| 571 | + of: find.textContaining('Result'), matching: find.byType(InkWell)) | |
| 572 | + .first; | |
| 573 | + final lh = tester.getRect(long).height; | |
| 574 | + // 167 before the text was clamped, and the clamp is what holds this: | |
| 575 | + // a character cap cannot, because it is the font and the width that | |
| 576 | + // decide how many lines 96 characters become. | |
| 577 | + expect(lh, lessThan(110), | |
| 578 | + reason: 'a long line makes a $lh-tall entry'); | |
| 579 | + expectLaidOut(tester, 'the overview on a phone'); | |
| 580 | + }); | |
| 581 | + | |
| 582 | + testWidgets('and the whole entry is the control, not an arrow', | |
| 583 | + (tester) async { | |
| 584 | + core.demoUi(); | |
| 585 | + core.dispatch('overview.toggle'); | |
| 586 | + await layOut(tester, sizes['phone']!); | |
| 587 | + // Scoped to the overview: a `→` also jumps to the line a reply | |
| 588 | + // answers, up in the backlog, and that one is not this one. | |
| 589 | + final card = find | |
| 590 | + .ancestor( | |
| 591 | + of: find.textContaining('sandbox-01'), matching: find.byType(InkWell)) | |
| 592 | + .first; | |
| 593 | + expect(find.descendant(of: card, matching: find.text('→')), findsNothing, | |
| 594 | + reason: 'the arrow button is back'); | |
| 595 | + }); | |
| 596 | + }); | |
| 536 | 597 | } |
| @@ -232,8 +232,14 @@ void main() { | |||
| 232 | core.demoUi(); | 232 | core.demoUi(); |
| 233 | core.dispatch('screen.chats'); | 233 | core.dispatch('screen.chats'); |
| 234 | await layOut(tester, sizes['desktop']!); | 234 | await layOut(tester, sizes['desktop']!); |
| 235 | - // The row's "Open", not its name: the name is a label in this list. | 235 | + // The row's "Open", not its name: the name is a label in this list — |
| 236 | - await tester.tap(find.text('Open').first); | 236 | + // and the Open belonging to #test, not whichever card is first. There |
| 237 | + // is more than one room in the demo now, and `.first` was a fact about | ||
| 238 | + // the order they happen to come back in. | ||
| 239 | + final card = find | ||
| 240 | + .ancestor(of: find.text('#test'), matching: find.byType(Container)) | ||
| 241 | + .last; | ||
| 242 | + await tester.tap(find.descendant(of: card, matching: find.text('Open'))); | ||
| 237 | await tester.pump(); | 243 | await tester.pump(); |
| 238 | expect(find.text('hello there'), findsWidgets, | 244 | expect(find.text('hello there'), findsWidgets, |
| 239 | reason: 'tapping the room did not open it'); | 245 | reason: 'tapping the room did not open it'); |
| @@ -533,4 +539,59 @@ void main() { | |||
| 533 | expectLaidOut(tester, 'a long room name'); | 539 | expectLaidOut(tester, 'a long room name'); |
| 534 | }); | 540 | }); |
| 535 | }); | 541 | }); |
| 542 | + | ||
| 543 | + group('the overview on a phone', () { | ||
| 544 | + testWidgets('an entry is two lines, not three', (tester) async { | ||
| 545 | + // The complaint, measured. Each entry was [room, sender, text, →] in a | ||
| 546 | + // Wrap: on a phone the arrow wrapped to a row of its own, so an entry | ||
| 547 | + // stood about three lines tall and most of that was a button. Counting | ||
| 548 | + // rows is unreliable across themes; the height of one entry is not. | ||
| 549 | + core.demoUi(); | ||
| 550 | + core.dispatch('overview.toggle'); | ||
| 551 | + await layOut(tester, sizes['phone']!); | ||
| 552 | + | ||
| 553 | + // The entry carrying a known line, found by what is in it rather than | ||
| 554 | + // by position among every InkWell on the screen. | ||
| 555 | + final entry = find | ||
| 556 | + .ancestor( | ||
| 557 | + of: find.textContaining('sandbox-01'), | ||
| 558 | + matching: find.byType(InkWell)) | ||
| 559 | + .first; | ||
| 560 | + expect(entry, findsOneWidget, reason: 'the entry is not tappable'); | ||
| 561 | + | ||
| 562 | + final h = tester.getRect(entry).height; | ||
| 563 | + expect(h, lessThan(96), | ||
| 564 | + reason: 'an overview entry is $h tall — three lines of furniture'); | ||
| 565 | + | ||
| 566 | + // The long one, which is what the complaint was actually about: a bot | ||
| 567 | + // line runs for paragraphs and a summary of it must still be an entry | ||
| 568 | + // in a list rather than a page of its own. | ||
| 569 | + final long = find | ||
| 570 | + .ancestor( | ||
| 571 | + of: find.textContaining('Result'), matching: find.byType(InkWell)) | ||
| 572 | + .first; | ||
| 573 | + final lh = tester.getRect(long).height; | ||
| 574 | + // 167 before the text was clamped, and the clamp is what holds this: | ||
| 575 | + // a character cap cannot, because it is the font and the width that | ||
| 576 | + // decide how many lines 96 characters become. | ||
| 577 | + expect(lh, lessThan(110), | ||
| 578 | + reason: 'a long line makes a $lh-tall entry'); | ||
| 579 | + expectLaidOut(tester, 'the overview on a phone'); | ||
| 580 | + }); | ||
| 581 | + | ||
| 582 | + testWidgets('and the whole entry is the control, not an arrow', | ||
| 583 | + (tester) async { | ||
| 584 | + core.demoUi(); | ||
| 585 | + core.dispatch('overview.toggle'); | ||
| 586 | + await layOut(tester, sizes['phone']!); | ||
| 587 | + // Scoped to the overview: a `→` also jumps to the line a reply | ||
| 588 | + // answers, up in the backlog, and that one is not this one. | ||
| 589 | + final card = find | ||
| 590 | + .ancestor( | ||
| 591 | + of: find.textContaining('sandbox-01'), matching: find.byType(InkWell)) | ||
| 592 | + .first; | ||
| 593 | + expect(find.descendant(of: card, matching: find.text('→')), findsNothing, | ||
| 594 | + reason: 'the arrow button is back'); | ||
| 595 | + }); | ||
| 596 | + }); | ||
| 536 | } | 597 | } |
modified
nim/src/frq/screens/chat.nim +23 -8 | @@ -286,22 +286,37 @@ proc overviewPane(s: State): Node = | ||
| 286 | 286 | ## `rooms.recentEverywhere`. Taking the newest hundred would be the strip |
| 287 | 287 | ## answering about whichever room is busiest, which is the one already on |
| 288 | 288 | ## screen. |
| 289 | - result = card(title2("Overview")) | |
| 290 | - var body = vbox(%*{"spacing": 4}) | |
| 289 | + # One card a line, and the card is the control. | |
| 290 | + # | |
| 291 | + # This was a single card holding rows of [room, sender, text, →]. In a | |
| 292 | + # Wrap on a phone that is three lines an entry: the sender on the first, | |
| 293 | + # the text on the second, and the arrow alone on a third -- a full-height | |
| 294 | + # button carrying no more meaning than the row it had been pushed off. | |
| 295 | + # Eight entries filled the screen with mostly furniture. | |
| 296 | + # | |
| 297 | + # So the arrow is gone and the whole card takes the press, which is both | |
| 298 | + # the larger target and the smaller thing to draw. What is left is what a | |
| 299 | + # reader actually skims: who said it and where, then what they said. | |
| 300 | + var body = vbox(%*{"spacing": 4}, title2("Overview")) | |
| 291 | 301 | var n = 0 |
| 292 | 302 | for m in recentEverywhere(s.rooms, s.current): |
| 293 | 303 | if n >= overviewLines: break |
| 294 | 304 | n += 1 |
| 295 | 305 | # Each line carries the room it was said in, since that is the one thing a |
| 296 | 306 | # line taken out of its own conversation no longer says for itself. |
| 297 | - body.children.add hbox(%*{"spacing": 6}, | |
| 298 | - dimLabel(m.room), | |
| 299 | - label(m.frm & ":"), | |
| 300 | - text(summarise(m.text, 48)), | |
| 301 | - button("→", "overview.goto:" & m.room & ":" & rowId(m))) | |
| 307 | + body.children.add card( | |
| 308 | + %*{"onClick": "overview.goto:" & m.room & ":" & rowId(m), | |
| 309 | + "spacing": 2}, | |
| 310 | + hbox(%*{"spacing": 6}, dimLabel(m.room), label(m.frm)), | |
| 311 | + # Two lines, and the cap is in lines rather than characters. How much | |
| 312 | + # fits on one is a fact about the font and the width, and a character | |
| 313 | + # count knows neither: 96 of them are two lines on a phone and five in | |
| 314 | + # a widget test. `summarise` still bounds what crosses the wire, but | |
| 315 | + # what a reader sees is bounded by the thing they are looking at. | |
| 316 | + text(summarise(m.text, 160), lines = 2)) | |
| 302 | 317 | if n == 0: |
| 303 | 318 | body.children.add dimLabel("Nothing has happened anywhere else.") |
| 304 | - result.children.add body | |
| 319 | + result = body | |
| 305 | 320 | |
| 306 | 321 | proc lightboxPane(s: State): Node = |
| 307 | 322 | ## The picture being looked at, as large as the window allows. |
| @@ -286,22 +286,37 @@ proc overviewPane(s: State): Node = | |||
| 286 | ## `rooms.recentEverywhere`. Taking the newest hundred would be the strip | 286 | ## `rooms.recentEverywhere`. Taking the newest hundred would be the strip |
| 287 | ## answering about whichever room is busiest, which is the one already on | 287 | ## answering about whichever room is busiest, which is the one already on |
| 288 | ## screen. | 288 | ## screen. |
| 289 | - result = card(title2("Overview")) | 289 | + # One card a line, and the card is the control. |
| 290 | - var body = vbox(%*{"spacing": 4}) | 290 | + # |
| 291 | + # This was a single card holding rows of [room, sender, text, →]. In a | ||
| 292 | + # Wrap on a phone that is three lines an entry: the sender on the first, | ||
| 293 | + # the text on the second, and the arrow alone on a third -- a full-height | ||
| 294 | + # button carrying no more meaning than the row it had been pushed off. | ||
| 295 | + # Eight entries filled the screen with mostly furniture. | ||
| 296 | + # | ||
| 297 | + # So the arrow is gone and the whole card takes the press, which is both | ||
| 298 | + # the larger target and the smaller thing to draw. What is left is what a | ||
| 299 | + # reader actually skims: who said it and where, then what they said. | ||
| 300 | + var body = vbox(%*{"spacing": 4}, title2("Overview")) | ||
| 291 | var n = 0 | 301 | var n = 0 |
| 292 | for m in recentEverywhere(s.rooms, s.current): | 302 | for m in recentEverywhere(s.rooms, s.current): |
| 293 | if n >= overviewLines: break | 303 | if n >= overviewLines: break |
| 294 | n += 1 | 304 | n += 1 |
| 295 | # Each line carries the room it was said in, since that is the one thing a | 305 | # Each line carries the room it was said in, since that is the one thing a |
| 296 | # line taken out of its own conversation no longer says for itself. | 306 | # line taken out of its own conversation no longer says for itself. |
| 297 | - body.children.add hbox(%*{"spacing": 6}, | 307 | + body.children.add card( |
| 298 | - dimLabel(m.room), | 308 | + %*{"onClick": "overview.goto:" & m.room & ":" & rowId(m), |
| 299 | - label(m.frm & ":"), | 309 | + "spacing": 2}, |
| 300 | - text(summarise(m.text, 48)), | 310 | + hbox(%*{"spacing": 6}, dimLabel(m.room), label(m.frm)), |
| 301 | - button("→", "overview.goto:" & m.room & ":" & rowId(m))) | 311 | + # Two lines, and the cap is in lines rather than characters. How much |
| 312 | + # fits on one is a fact about the font and the width, and a character | ||
| 313 | + # count knows neither: 96 of them are two lines on a phone and five in | ||
| 314 | + # a widget test. `summarise` still bounds what crosses the wire, but | ||
| 315 | + # what a reader sees is bounded by the thing they are looking at. | ||
| 316 | + text(summarise(m.text, 160), lines = 2)) | ||
| 302 | if n == 0: | 317 | if n == 0: |
| 303 | body.children.add dimLabel("Nothing has happened anywhere else.") | 318 | body.children.add dimLabel("Nothing has happened anywhere else.") |
| 304 | - result.children.add body | 319 | + result = body |
| 305 | 320 | ||
| 306 | proc lightboxPane(s: State): Node = | 321 | proc lightboxPane(s: State): Node = |
| 307 | ## The picture being looked at, as large as the window allows. | 322 | ## The picture being looked at, as large as the window allows. |
modified
nim/src/frq/ui.nim +11 -2 | @@ -56,6 +56,10 @@ func hbox*(props: JsonNode, children: varargs[Node]): Node = | ||
| 56 | 56 | n("hbox", props, @children) |
| 57 | 57 | func card*(children: varargs[Node]): Node = |
| 58 | 58 | n("card", newJObject(), @children) |
| 59 | +func card*(props: JsonNode, children: varargs[Node]): Node = | |
| 60 | + ## A card with props — `onClick` makes the whole card the control, for a | |
| 61 | + ## list whose every line goes somewhere. | |
| 62 | + n("card", props, @children) | |
| 59 | 63 | func page*(props: JsonNode, children: varargs[Node]): Node = |
| 60 | 64 | n("page", props, @children) |
| 61 | 65 | |
| @@ -118,9 +122,14 @@ func paragraph*(children: varargs[Node]): Node = | ||
| 118 | 122 | ## contradiction before every row it drew. |
| 119 | 123 | n("paragraph", newJObject(), @children) |
| 120 | 124 | |
| 121 | -func text*(body: string): Node = | |
| 125 | +func text*(body: string, lines = 0): Node = | |
| 122 | 126 | ## Prose, as opposed to a `label`: wraps, and is the thing a message is. |
| 123 | - n("text", %*{"text": body}) | |
| 127 | + ## | |
| 128 | + ## `lines` caps how many it may take, ellipsising after. Left at 0 it says | |
| 129 | + ## all of itself, which is what a message in a conversation does. | |
| 130 | + var p = %*{"text": body} | |
| 131 | + if lines > 0: p["lines"] = %lines | |
| 132 | + n("text", p) | |
| 124 | 133 | |
| 125 | 134 | func link*(label, url: string): Node = |
| 126 | 135 | n("link", %*{"label": label, "url": url}) |
| @@ -56,6 +56,10 @@ func hbox*(props: JsonNode, children: varargs[Node]): Node = | |||
| 56 | n("hbox", props, @children) | 56 | n("hbox", props, @children) |
| 57 | func card*(children: varargs[Node]): Node = | 57 | func card*(children: varargs[Node]): Node = |
| 58 | n("card", newJObject(), @children) | 58 | n("card", newJObject(), @children) |
| 59 | +func card*(props: JsonNode, children: varargs[Node]): Node = | ||
| 60 | + ## A card with props — `onClick` makes the whole card the control, for a | ||
| 61 | + ## list whose every line goes somewhere. | ||
| 62 | + n("card", props, @children) | ||
| 59 | func page*(props: JsonNode, children: varargs[Node]): Node = | 63 | func page*(props: JsonNode, children: varargs[Node]): Node = |
| 60 | n("page", props, @children) | 64 | n("page", props, @children) |
| 61 | 65 | ||
| @@ -118,9 +122,14 @@ func paragraph*(children: varargs[Node]): Node = | |||
| 118 | ## contradiction before every row it drew. | 122 | ## contradiction before every row it drew. |
| 119 | n("paragraph", newJObject(), @children) | 123 | n("paragraph", newJObject(), @children) |
| 120 | 124 | ||
| 121 | -func text*(body: string): Node = | 125 | +func text*(body: string, lines = 0): Node = |
| 122 | ## Prose, as opposed to a `label`: wraps, and is the thing a message is. | 126 | ## Prose, as opposed to a `label`: wraps, and is the thing a message is. |
| 123 | - n("text", %*{"text": body}) | 127 | + ## |
| 128 | + ## `lines` caps how many it may take, ellipsising after. Left at 0 it says | ||
| 129 | + ## all of itself, which is what a message in a conversation does. | ||
| 130 | + var p = %*{"text": body} | ||
| 131 | + if lines > 0: p["lines"] = %lines | ||
| 132 | + n("text", p) | ||
| 124 | 133 | ||
| 125 | func link*(label, url: string): Node = | 134 | func link*(label, url: string): Node = |
| 126 | n("link", %*{"label": label, "url": url}) | 135 | n("link", %*{"label": label, "url": url}) |
modified
nim/src/frq_core.nim +18 -0 | @@ -247,6 +247,24 @@ proc frq_ui_demo*() {.exportc, dynlib.} = | ||
| 247 | 247 | # A different day, so a heading has to land between them. |
| 248 | 248 | Message(id: "8", frm: "alice", text: "next day", at: t0 + 200_000_000)] |
| 249 | 249 | app.rooms["#test"] = r |
| 250 | + | |
| 251 | + # Somewhere else, because the overview is about every room but this one and | |
| 252 | + # with a single room there was nothing for it to show. The test that laid | |
| 253 | + # the overview out was laying out "Nothing has happened anywhere else." | |
| 254 | + # | |
| 255 | + # Awkward on purpose, like the rest: a long bot line that has to be cut, | |
| 256 | + # and a short one that must not look different for it. | |
| 257 | + app.rooms.ensureRoom("#tasks") | |
| 258 | + var other = app.rooms["#tasks"] | |
| 259 | + other.joined = true | |
| 260 | + other.messages = @[ | |
| 261 | + Message(id: "o1", frm: "freeq-bot", text: "sandbox-01: I am sandbox 1 of 50.", | |
| 262 | + at: t0 + 100_000), | |
| 263 | + Message(id: "o2", frm: "freeq-bot", | |
| 264 | + text: "**Result**: 50/50 announcements collected in ~12s, and the rest of a sentence that has to be cut somewhere sensible.", | |
| 265 | + at: t0 + 200_000)] | |
| 266 | + app.rooms["#tasks"] = other | |
| 267 | + | |
| 250 | 268 | app.current = "#test" |
| 251 | 269 | app.screen = scChat |
| 252 | 270 | app.status = "Connected as me" |
| @@ -247,6 +247,24 @@ proc frq_ui_demo*() {.exportc, dynlib.} = | |||
| 247 | # A different day, so a heading has to land between them. | 247 | # A different day, so a heading has to land between them. |
| 248 | Message(id: "8", frm: "alice", text: "next day", at: t0 + 200_000_000)] | 248 | Message(id: "8", frm: "alice", text: "next day", at: t0 + 200_000_000)] |
| 249 | app.rooms["#test"] = r | 249 | app.rooms["#test"] = r |
| 250 | + | ||
| 251 | + # Somewhere else, because the overview is about every room but this one and | ||
| 252 | + # with a single room there was nothing for it to show. The test that laid | ||
| 253 | + # the overview out was laying out "Nothing has happened anywhere else." | ||
| 254 | + # | ||
| 255 | + # Awkward on purpose, like the rest: a long bot line that has to be cut, | ||
| 256 | + # and a short one that must not look different for it. | ||
| 257 | + app.rooms.ensureRoom("#tasks") | ||
| 258 | + var other = app.rooms["#tasks"] | ||
| 259 | + other.joined = true | ||
| 260 | + other.messages = @[ | ||
| 261 | + Message(id: "o1", frm: "freeq-bot", text: "sandbox-01: I am sandbox 1 of 50.", | ||
| 262 | + at: t0 + 100_000), | ||
| 263 | + Message(id: "o2", frm: "freeq-bot", | ||
| 264 | + text: "**Result**: 50/50 announcements collected in ~12s, and the rest of a sentence that has to be cut somewhere sensible.", | ||
| 265 | + at: t0 + 200_000)] | ||
| 266 | + app.rooms["#tasks"] = other | ||
| 267 | + | ||
| 250 | app.current = "#test" | 268 | app.current = "#test" |
| 251 | app.screen = scChat | 269 | app.screen = scChat |
| 252 | app.status = "Connected as me" | 270 | app.status = "Connected as me" |
modified
nim/tests/tchat.nim +12 -0 | @@ -266,6 +266,18 @@ suite "the overview": | ||
| 266 | 266 | .anyIt("hello" in it.props{"text"}.getStr() and |
| 267 | 267 | it.props{"text"}.getStr() != "hello") |
| 268 | 268 | |
| 269 | + test "each line is a card that is itself the way there": | |
| 270 | + # It used to be a row ending in a "→" button. In a Wrap on a phone the | |
| 271 | + # button landed on a line of its own, so every entry cost three rows and | |
| 272 | + # the smallest thing on screen was the only part that could be pressed. | |
| 273 | + app.overview = true | |
| 274 | + let t = cs.chatScreen(app, true) | |
| 275 | + check "→" notin t.labels("button") | |
| 276 | + let cards = t.find("card").filterIt( | |
| 277 | + it.props{"onClick"}.getStr().startsWith("overview.goto:")) | |
| 278 | + check cards.len == 1 | |
| 279 | + check cards[0].props{"onClick"}.getStr() == "overview.goto:#other:o1" | |
| 280 | + | |
| 269 | 281 | test "an empty one says so": |
| 270 | 282 | var lonely = withRoom() |
| 271 | 283 | lonely.overview = true |
| @@ -266,6 +266,18 @@ suite "the overview": | |||
| 266 | .anyIt("hello" in it.props{"text"}.getStr() and | 266 | .anyIt("hello" in it.props{"text"}.getStr() and |
| 267 | it.props{"text"}.getStr() != "hello") | 267 | it.props{"text"}.getStr() != "hello") |
| 268 | 268 | ||
| 269 | + test "each line is a card that is itself the way there": | ||
| 270 | + # It used to be a row ending in a "→" button. In a Wrap on a phone the | ||
| 271 | + # button landed on a line of its own, so every entry cost three rows and | ||
| 272 | + # the smallest thing on screen was the only part that could be pressed. | ||
| 273 | + app.overview = true | ||
| 274 | + let t = cs.chatScreen(app, true) | ||
| 275 | + check "→" notin t.labels("button") | ||
| 276 | + let cards = t.find("card").filterIt( | ||
| 277 | + it.props{"onClick"}.getStr().startsWith("overview.goto:")) | ||
| 278 | + check cards.len == 1 | ||
| 279 | + check cards[0].props{"onClick"}.getStr() == "overview.goto:#other:o1" | ||
| 280 | + | ||
| 269 | test "an empty one says so": | 281 | test "an empty one says so": |
| 270 | var lonely = withRoom() | 282 | var lonely = withRoom() |
| 271 | lonely.overview = true | 283 | lonely.overview = true |