A window with two edges, and something against each of them
Four things asked for, and underneath them a fifth that made three of the four impossible. Nothing ever told the core how big the window was. `windowWidth` was zero on every window there has ever been, so `wide` was always false — and with it the whole side-by-side layout, and the button that folds the room list, which is only offered on a wide one. The renderer now reports the size it was given, from the constraints rather than from MediaQuery, because the space the tree actually gets is what the decision is about. That is the third flag this week that nothing fed. The chips ride the right edge. `align: end` on them never did anything: the sender's row was a Wrap, which packs from the left and has no slack to align with. It is a row now, with a `stretch` after the name that takes the slack — and the name is `Flexible`, so on a row too narrow for everything it is the part that gives way. On a phone even that is not enough: with the name at nothing, the face, the time and three chips still ask for more than 360 points has, which is why the row was a Wrap to begin with and why on a narrow window it still is. The room list rides beside the conversation when it is up. Names rather than the cards the chats screen uses — a card is a screen's worth of width and this is a strip. And the people list is a strip too, rather than the pane. It used to take the whole of it on a narrow window, so asking who was in a room meant losing the room while you looked. Neither strip scrolls, and that is a limit rather than a decision: a `scroll` becomes an `Expanded` and a `vbox` is always `MainAxisSize.min`, which is the one combination Flutter will not lay out — it came back as a semantics assertion naming neither. A list longer than the window will run off the bottom until a vbox can be told to fill its parent. The first `window.size` test passed against a reducer that read the id while the renderer sent a value, so it tested a shape nothing sends. It now uses the renderer's. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
0e7cd32 parent: 23846db modified
flutter/lib/nim_renderer.dart +48 -2 | @@ -60,6 +60,11 @@ class _NimAppState extends State<NimApp> { | ||
| 60 | 60 | // switching rooms is a different backlog at a different offset. |
| 61 | 61 | final _scrollers = <String, ScrollController>{}; |
| 62 | 62 | |
| 63 | + // The last size reported to the core, so a rebuild that changed nothing | |
| 64 | + // does not dispatch. | |
| 65 | + int _reportedW = 0; | |
| 66 | + int _reportedH = 0; | |
| 67 | + | |
| 63 | 68 | // Where a "go to that message" is pointing, for the one frame it is |
| 64 | 69 | // pointing there. The core marks the row with `scrollHere`, this finds it |
| 65 | 70 | // after the frame is laid out — `ensureVisible` needs a built element, so |
| @@ -144,7 +149,31 @@ class _NimAppState extends State<NimApp> { | ||
| 144 | 149 | // faces and reaction pills under here keep their gestures. |
| 145 | 150 | home: Scaffold( |
| 146 | 151 | backgroundColor: t.bg, |
| 147 | - body: SafeArea(child: SelectionArea(child: _build(_tree))), | |
| 152 | + // The core decides what a window this size can hold — whether the | |
| 153 | + // room list rides beside the conversation, whether there is a back | |
| 154 | + // button — and it cannot measure one. A window is the host's, like | |
| 155 | + // a socket or a clock, so the host says. | |
| 156 | + // | |
| 157 | + // From the constraints rather than MediaQuery: this is the space | |
| 158 | + // the tree is actually given, which is what the decision is about. | |
| 159 | + // Reported after the frame, because a dispatch is a setState and a | |
| 160 | + // setState during build is an error. | |
| 161 | + body: SafeArea( | |
| 162 | + child: LayoutBuilder( | |
| 163 | + builder: (context, constraints) { | |
| 164 | + final w = constraints.maxWidth.round(); | |
| 165 | + final h = constraints.maxHeight.round(); | |
| 166 | + if (w != _reportedW || h != _reportedH) { | |
| 167 | + _reportedW = w; | |
| 168 | + _reportedH = h; | |
| 169 | + WidgetsBinding.instance.addPostFrameCallback((_) { | |
| 170 | + if (mounted) _send('window.size', '${w}x$h'); | |
| 171 | + }); | |
| 172 | + } | |
| 173 | + return SelectionArea(child: _build(_tree)); | |
| 174 | + }, | |
| 175 | + ), | |
| 176 | + ), | |
| 148 | 177 | ), |
| 149 | 178 | ); |
| 150 | 179 | |
| @@ -456,6 +485,12 @@ class _NimAppState extends State<NimApp> { | ||
| 456 | 485 | |
| 457 | 486 | case 'spacer': |
| 458 | 487 | { |
| 488 | + // A gap that takes whatever is left, when it says so. That is what | |
| 489 | + // carries a row's last children to its far edge: `align: end` on a | |
| 490 | + // row cannot, because a Row with no slack has nothing to align. | |
| 491 | + if (n.prop('expand', false) && flex) { | |
| 492 | + return const Spacer(); | |
| 493 | + } | |
| 459 | 494 | final s = _d(n.props['size'], t.spaceXxs); |
| 460 | 495 | return SizedBox(width: s, height: s); |
| 461 | 496 | } |
| @@ -507,11 +542,22 @@ class _NimAppState extends State<NimApp> { | ||
| 507 | 542 | // middle of a line and must not look like a control. Text that |
| 508 | 543 | // takes a press, with no chrome at all. |
| 509 | 544 | if (kind == 'plain') { |
| 510 | - return InkWell( | |
| 545 | + final plain = InkWell( | |
| 511 | 546 | onTap: () => _send(onClick), |
| 512 | 547 | child: Text(n.prop('label', ''), |
| 548 | + maxLines: 1, | |
| 549 | + overflow: TextOverflow.ellipsis, | |
| 513 | 550 | style: _style(t.textBody, t.onBg)), |
| 514 | 551 | ); |
| 552 | + // `Flexible` and not `Expanded`: a name takes the width it needs | |
| 553 | + // and gives the rest back, but on a row too narrow for everything | |
| 554 | + // it is the part that should shrink. A handle is long, and the | |
| 555 | + // time and the chips beside it are not negotiable — so without | |
| 556 | + // this the sender's row overflowed by however much the name was | |
| 557 | + // over, which on a phone was most handles. | |
| 558 | + return (n.prop('expand', false) && flex) | |
| 559 | + ? Flexible(child: plain) | |
| 560 | + : plain; | |
| 515 | 561 | } |
| 516 | 562 | if (kind == 'destructive') { |
| 517 | 563 | return FilledButton( |
| @@ -60,6 +60,11 @@ class _NimAppState extends State<NimApp> { | |||
| 60 | // switching rooms is a different backlog at a different offset. | 60 | // switching rooms is a different backlog at a different offset. |
| 61 | final _scrollers = <String, ScrollController>{}; | 61 | final _scrollers = <String, ScrollController>{}; |
| 62 | 62 | ||
| 63 | + // The last size reported to the core, so a rebuild that changed nothing | ||
| 64 | + // does not dispatch. | ||
| 65 | + int _reportedW = 0; | ||
| 66 | + int _reportedH = 0; | ||
| 67 | + | ||
| 63 | // Where a "go to that message" is pointing, for the one frame it is | 68 | // Where a "go to that message" is pointing, for the one frame it is |
| 64 | // pointing there. The core marks the row with `scrollHere`, this finds it | 69 | // pointing there. The core marks the row with `scrollHere`, this finds it |
| 65 | // after the frame is laid out — `ensureVisible` needs a built element, so | 70 | // after the frame is laid out — `ensureVisible` needs a built element, so |
| @@ -144,7 +149,31 @@ class _NimAppState extends State<NimApp> { | |||
| 144 | // faces and reaction pills under here keep their gestures. | 149 | // faces and reaction pills under here keep their gestures. |
| 145 | home: Scaffold( | 150 | home: Scaffold( |
| 146 | backgroundColor: t.bg, | 151 | backgroundColor: t.bg, |
| 147 | - body: SafeArea(child: SelectionArea(child: _build(_tree))), | 152 | + // The core decides what a window this size can hold — whether the |
| 153 | + // room list rides beside the conversation, whether there is a back | ||
| 154 | + // button — and it cannot measure one. A window is the host's, like | ||
| 155 | + // a socket or a clock, so the host says. | ||
| 156 | + // | ||
| 157 | + // From the constraints rather than MediaQuery: this is the space | ||
| 158 | + // the tree is actually given, which is what the decision is about. | ||
| 159 | + // Reported after the frame, because a dispatch is a setState and a | ||
| 160 | + // setState during build is an error. | ||
| 161 | + body: SafeArea( | ||
| 162 | + child: LayoutBuilder( | ||
| 163 | + builder: (context, constraints) { | ||
| 164 | + final w = constraints.maxWidth.round(); | ||
| 165 | + final h = constraints.maxHeight.round(); | ||
| 166 | + if (w != _reportedW || h != _reportedH) { | ||
| 167 | + _reportedW = w; | ||
| 168 | + _reportedH = h; | ||
| 169 | + WidgetsBinding.instance.addPostFrameCallback((_) { | ||
| 170 | + if (mounted) _send('window.size', '${w}x$h'); | ||
| 171 | + }); | ||
| 172 | + } | ||
| 173 | + return SelectionArea(child: _build(_tree)); | ||
| 174 | + }, | ||
| 175 | + ), | ||
| 176 | + ), | ||
| 148 | ), | 177 | ), |
| 149 | ); | 178 | ); |
| 150 | 179 | ||
| @@ -456,6 +485,12 @@ class _NimAppState extends State<NimApp> { | |||
| 456 | 485 | ||
| 457 | case 'spacer': | 486 | case 'spacer': |
| 458 | { | 487 | { |
| 488 | + // A gap that takes whatever is left, when it says so. That is what | ||
| 489 | + // carries a row's last children to its far edge: `align: end` on a | ||
| 490 | + // row cannot, because a Row with no slack has nothing to align. | ||
| 491 | + if (n.prop('expand', false) && flex) { | ||
| 492 | + return const Spacer(); | ||
| 493 | + } | ||
| 459 | final s = _d(n.props['size'], t.spaceXxs); | 494 | final s = _d(n.props['size'], t.spaceXxs); |
| 460 | return SizedBox(width: s, height: s); | 495 | return SizedBox(width: s, height: s); |
| 461 | } | 496 | } |
| @@ -507,11 +542,22 @@ class _NimAppState extends State<NimApp> { | |||
| 507 | // middle of a line and must not look like a control. Text that | 542 | // middle of a line and must not look like a control. Text that |
| 508 | // takes a press, with no chrome at all. | 543 | // takes a press, with no chrome at all. |
| 509 | if (kind == 'plain') { | 544 | if (kind == 'plain') { |
| 510 | - return InkWell( | 545 | + final plain = InkWell( |
| 511 | onTap: () => _send(onClick), | 546 | onTap: () => _send(onClick), |
| 512 | child: Text(n.prop('label', ''), | 547 | child: Text(n.prop('label', ''), |
| 548 | + maxLines: 1, | ||
| 549 | + overflow: TextOverflow.ellipsis, | ||
| 513 | style: _style(t.textBody, t.onBg)), | 550 | style: _style(t.textBody, t.onBg)), |
| 514 | ); | 551 | ); |
| 552 | + // `Flexible` and not `Expanded`: a name takes the width it needs | ||
| 553 | + // and gives the rest back, but on a row too narrow for everything | ||
| 554 | + // it is the part that should shrink. A handle is long, and the | ||
| 555 | + // time and the chips beside it are not negotiable — so without | ||
| 556 | + // this the sender's row overflowed by however much the name was | ||
| 557 | + // over, which on a phone was most handles. | ||
| 558 | + return (n.prop('expand', false) && flex) | ||
| 559 | + ? Flexible(child: plain) | ||
| 560 | + : plain; | ||
| 515 | } | 561 | } |
| 516 | if (kind == 'destructive') { | 562 | if (kind == 'destructive') { |
| 517 | return FilledButton( | 563 | return FilledButton( |
modified
nim/src/frq/reducer.nim +23 -0 | @@ -438,6 +438,29 @@ proc dispatch*(event: JsonNode) = | ||
| 438 | 438 | of "present.left": app.atPresent = false |
| 439 | 439 | of "present.back": app.atPresent = true |
| 440 | 440 | |
| 441 | + of "window.size": | |
| 442 | + # `<width>x<height>`, from whatever is drawing. The core decides what a | |
| 443 | + # window that size can hold — whether the room list rides beside the | |
| 444 | + # conversation, whether there is a back button — and it cannot measure | |
| 445 | + # one: a window is the host's, like a socket or a clock. | |
| 446 | + # | |
| 447 | + # Nothing sent this until now, so `windowWidth` was zero and `wide` was | |
| 448 | + # false on every window there has ever been. The whole side-by-side | |
| 449 | + # layout was unreachable, and the button that folds the room list was | |
| 450 | + # never drawn — it is only offered on a wide one. | |
| 451 | + # In the value where the renderer puts it, or after the colon where a | |
| 452 | + # test or a console types it. Both, because the first version read only | |
| 453 | + # the id and the test that passed was the one written to match it — the | |
| 454 | + # renderer was sending a value nothing looked at. | |
| 455 | + let spec = if value.len > 0: value else: arg | |
| 456 | + let x = spec.find('x') | |
| 457 | + if x > 0: | |
| 458 | + let w = try: parseInt(spec[0 ..< x]) except ValueError: 0 | |
| 459 | + let h = try: parseInt(spec[x + 1 .. ^1]) except ValueError: 0 | |
| 460 | + if w > 0 and h > 0: | |
| 461 | + app.windowWidth = w | |
| 462 | + app.windowHeight = h | |
| 463 | + | |
| 441 | 464 | # ------------------------------------------------------------ the compose |
| 442 | 465 | of "draft.change": app.draft = value |
| 443 | 466 | of "send": sendDraft() |
| @@ -438,6 +438,29 @@ proc dispatch*(event: JsonNode) = | |||
| 438 | of "present.left": app.atPresent = false | 438 | of "present.left": app.atPresent = false |
| 439 | of "present.back": app.atPresent = true | 439 | of "present.back": app.atPresent = true |
| 440 | 440 | ||
| 441 | + of "window.size": | ||
| 442 | + # `<width>x<height>`, from whatever is drawing. The core decides what a | ||
| 443 | + # window that size can hold — whether the room list rides beside the | ||
| 444 | + # conversation, whether there is a back button — and it cannot measure | ||
| 445 | + # one: a window is the host's, like a socket or a clock. | ||
| 446 | + # | ||
| 447 | + # Nothing sent this until now, so `windowWidth` was zero and `wide` was | ||
| 448 | + # false on every window there has ever been. The whole side-by-side | ||
| 449 | + # layout was unreachable, and the button that folds the room list was | ||
| 450 | + # never drawn — it is only offered on a wide one. | ||
| 451 | + # In the value where the renderer puts it, or after the colon where a | ||
| 452 | + # test or a console types it. Both, because the first version read only | ||
| 453 | + # the id and the test that passed was the one written to match it — the | ||
| 454 | + # renderer was sending a value nothing looked at. | ||
| 455 | + let spec = if value.len > 0: value else: arg | ||
| 456 | + let x = spec.find('x') | ||
| 457 | + if x > 0: | ||
| 458 | + let w = try: parseInt(spec[0 ..< x]) except ValueError: 0 | ||
| 459 | + let h = try: parseInt(spec[x + 1 .. ^1]) except ValueError: 0 | ||
| 460 | + if w > 0 and h > 0: | ||
| 461 | + app.windowWidth = w | ||
| 462 | + app.windowHeight = h | ||
| 463 | + | ||
| 441 | # ------------------------------------------------------------ the compose | 464 | # ------------------------------------------------------------ the compose |
| 442 | of "draft.change": app.draft = value | 465 | of "draft.change": app.draft = value |
| 443 | of "send": sendDraft() | 466 | of "send": sendDraft() |
modified
nim/src/frq/screens/chat.nim +60 -10 | @@ -24,6 +24,10 @@ const | ||
| 24 | 24 | chipGap = 4 |
| 25 | 25 | overviewLines* = 8 |
| 26 | 26 | |
| 27 | + sidePanelWidth = 150 | |
| 28 | + ## What a strip beside the backlog takes. Wide enough for a room name or | |
| 29 | + ## a nick, narrow enough that the conversation is still the pane. | |
| 30 | + | |
| 27 | 31 | func actionChips(m: Message, mine: bool): Node = |
| 28 | 32 | ## Answering and reacting, on the sender's row above the message. |
| 29 | 33 | ## |
| @@ -147,13 +151,23 @@ proc messageBody(s: State, room: Room, m: Message, highlit: bool): Node = | ||
| 147 | 151 | if m.account.len > 0: m.account else: s.dids.getOrDefault(m.frm, ""), |
| 148 | 152 | m.frm) |
| 149 | 153 | let open = "profile.open:" & m.frm & ":" & senderActor |
| 150 | - var row = hbox(%*{"spacing": 6}, | |
| 154 | + # A row where there is room for one, a Wrap where there is not. | |
| 155 | + # | |
| 156 | + # The chips ride the right edge by way of the `stretch` below, and a | |
| 157 | + # stretch needs slack to take. On a phone there is none: with the name | |
| 158 | + # shrunk to nothing, the face, the time and three chips still ask for | |
| 159 | + # more than 360 points has — which is why this was a Wrap to begin with, | |
| 160 | + # and why on a narrow window it still is, putting the chips on a second | |
| 161 | + # line rather than off the edge. | |
| 162 | + var row = hbox(%*{"spacing": 6, "wrap": not s.wide}, | |
| 151 | 163 | # From the profile cache rather than the message: a face belongs to a |
| 152 | 164 | # person, not to a line they said, and a profile that arrives after |
| 153 | 165 | # their first message should appear on all of them. |
| 154 | 166 | avatar(avatarFor(senderActor, m.frm), m.frm, size = faceSize, |
| 155 | 167 | onClick = open), |
| 156 | - n("button", %*{"label": m.frm, "kind": "plain", "onClick": open})) | |
| 168 | + n("button", %*{"label": m.frm, "kind": "plain", "onClick": open, | |
| 169 | + "expand": s.wide})) | |
| 170 | + if s.wide: row.children.add stretch() | |
| 157 | 171 | if m.at > 0: |
| 158 | 172 | row.children.add dimLabel(clockTime(m.at)) |
| 159 | 173 | if m.edited: |
| @@ -350,9 +364,11 @@ proc chatScreen*(s: State, connected: bool): Node = | ||
| 350 | 364 | let name = if room.name.len > 0: room.name else: "Chat" |
| 351 | 365 | let isChannel = room.name.startsWith("#") |
| 352 | 366 | let showUsers = s.showUsers and isChannel |
| 353 | - # Beside the backlog only where there is room for both. On a narrow window | |
| 354 | - # the panel is the pane, and the backlog stands down for as long as it is up. | |
| 355 | - let narrowPeople = showUsers and not s.wide | |
| 367 | + # The chat list rides beside the backlog on a wide window. On a narrow one | |
| 368 | + # there is `← Chats`, which goes to the list as a screen of its own — a | |
| 369 | + # panel and a whole-screen list in the same place would be two ways to the | |
| 370 | + # same thing, one of them cramped. | |
| 371 | + let showChatList = s.wide and not s.hideChatList | |
| 356 | 372 | |
| 357 | 373 | # Wrapping, because on a phone this row asks for more than there is: ← Chats, |
| 358 | 374 | # the room's name, People and Overview do not fit across 360 points, and in a |
| @@ -393,8 +409,12 @@ proc chatScreen*(s: State, connected: bool): Node = | ||
| 393 | 409 | |
| 394 | 410 | # The backlog. Not a page — a page scrolls everything, which would carry the |
| 395 | 411 | # compose bar off the bottom with the messages. |
| 396 | - var messages = vbox(%*{"key": "messages", "expand": not narrowPeople}) | |
| 397 | - if not narrowPeople: | |
| 412 | + # The backlog always has the middle, and always expands: a panel coming up | |
| 413 | + # beside it takes a strip of the width, not the pane. It used to take the | |
| 414 | + # whole of it on a narrow window, so asking who was in a room meant losing | |
| 415 | + # the room while you looked. | |
| 416 | + var messages = vbox(%*{"key": "messages", "expand": true}) | |
| 417 | + block: | |
| 398 | 418 | var sc = scroll(%*{"scrollKey": "messages-" & room.name, |
| 399 | 419 | "orientation": "vertical", |
| 400 | 420 | "stickToBottom": true, |
| @@ -407,14 +427,42 @@ proc chatScreen*(s: State, connected: bool): Node = | ||
| 407 | 427 | sc.children.add dimLabel("Nothing here yet.") |
| 408 | 428 | messages.children.add sc |
| 409 | 429 | |
| 430 | + # The rooms, beside the one being read. Names rather than the cards the | |
| 431 | + # chats screen uses: a card carries a preview and two buttons and is a | |
| 432 | + # screen's worth of width, where this is a strip down one side. | |
| 433 | + # | |
| 434 | + # Neither strip scrolls, and that is a limit rather than a decision. A | |
| 435 | + # `scroll` becomes an `Expanded`, and a `vbox` is always `MainAxisSize.min` | |
| 436 | + # — which is the one combination Flutter will not lay out, and it came back | |
| 437 | + # as a semantics assertion rather than anything mentioning either. A room | |
| 438 | + # list or a member list longer than the window will run off the bottom | |
| 439 | + # until a vbox can be told to fill its parent. | |
| 440 | + var chatListPane = vbox(%*{"key": "chat-list-pane"}) | |
| 441 | + if showChatList: | |
| 442 | + var panel = vbox(%*{"spacing": 4, "widthRequest": sidePanelWidth}, | |
| 443 | + title2("Chats")) | |
| 444 | + var list = vbox(%*{"key": "chat-list", "spacing": 4}) | |
| 445 | + for r in channelList(s.rooms, ""): | |
| 446 | + let unread = if r.unread > 0: " " & (if r.mention: "◆ " else: "● ") & | |
| 447 | + $r.unread | |
| 448 | + else: "" | |
| 449 | + list.children.add n("button", | |
| 450 | + %*{"key": "side-" & r.name, "label": r.name & unread, | |
| 451 | + "kind": (if r.name == s.current: "primary" else: "plain"), | |
| 452 | + "onClick": "room.open:" & r.name}) | |
| 453 | + panel.children.add list | |
| 454 | + chatListPane.children.add panel | |
| 455 | + | |
| 410 | 456 | var peoplePane = vbox(%*{"key": "people-pane"}) |
| 411 | 457 | if showUsers: |
| 412 | - var panel = vbox(%*{"spacing": 4, "widthRequest": 150}, | |
| 458 | + var panel = vbox(%*{"spacing": 4, "widthRequest": sidePanelWidth}, | |
| 413 | 459 | title2("People")) |
| 460 | + var list = vbox(%*{"key": "people-list", "spacing": 2}) | |
| 414 | 461 | # Ops first, then alphabetically, with the mode prefix in front of the |
| 415 | 462 | # name — the order every other client lists them in. |
| 416 | 463 | for m in memberList(room.users): |
| 417 | - panel.children.add label(m.prefix & m.nick) | |
| 464 | + list.children.add label(m.prefix & m.nick) | |
| 465 | + panel.children.add list | |
| 418 | 466 | peoplePane.children.add panel |
| 419 | 467 | |
| 420 | 468 | # Both panels are in wrappers that are always there, for the reason the |
| @@ -485,8 +533,10 @@ proc chatScreen*(s: State, connected: bool): Node = | ||
| 485 | 533 | # The jump button floats over the backlog rather than taking a row of |
| 486 | 534 | # its own; see `ui.overlay`. |
| 487 | 535 | overlay(%*{"key": "backlog", "expand": true}, |
| 536 | + # The split: the rooms on one side, the people on the other, and the | |
| 537 | + # conversation between them taking whatever is left. | |
| 488 | 538 | n("hbox", %*{"spacing": 8, "wrap": false, "expand": true}, |
| 489 | - @[messages, peoplePane]), | |
| 539 | + @[chatListPane, messages, peoplePane]), | |
| 490 | 540 | jump), |
| 491 | 541 | overview, |
| 492 | 542 | profile, |
| @@ -24,6 +24,10 @@ const | |||
| 24 | chipGap = 4 | 24 | chipGap = 4 |
| 25 | overviewLines* = 8 | 25 | overviewLines* = 8 |
| 26 | 26 | ||
| 27 | + sidePanelWidth = 150 | ||
| 28 | + ## What a strip beside the backlog takes. Wide enough for a room name or | ||
| 29 | + ## a nick, narrow enough that the conversation is still the pane. | ||
| 30 | + | ||
| 27 | func actionChips(m: Message, mine: bool): Node = | 31 | func actionChips(m: Message, mine: bool): Node = |
| 28 | ## Answering and reacting, on the sender's row above the message. | 32 | ## Answering and reacting, on the sender's row above the message. |
| 29 | ## | 33 | ## |
| @@ -147,13 +151,23 @@ proc messageBody(s: State, room: Room, m: Message, highlit: bool): Node = | |||
| 147 | if m.account.len > 0: m.account else: s.dids.getOrDefault(m.frm, ""), | 151 | if m.account.len > 0: m.account else: s.dids.getOrDefault(m.frm, ""), |
| 148 | m.frm) | 152 | m.frm) |
| 149 | let open = "profile.open:" & m.frm & ":" & senderActor | 153 | let open = "profile.open:" & m.frm & ":" & senderActor |
| 150 | - var row = hbox(%*{"spacing": 6}, | 154 | + # A row where there is room for one, a Wrap where there is not. |
| 155 | + # | ||
| 156 | + # The chips ride the right edge by way of the `stretch` below, and a | ||
| 157 | + # stretch needs slack to take. On a phone there is none: with the name | ||
| 158 | + # shrunk to nothing, the face, the time and three chips still ask for | ||
| 159 | + # more than 360 points has — which is why this was a Wrap to begin with, | ||
| 160 | + # and why on a narrow window it still is, putting the chips on a second | ||
| 161 | + # line rather than off the edge. | ||
| 162 | + var row = hbox(%*{"spacing": 6, "wrap": not s.wide}, | ||
| 151 | # From the profile cache rather than the message: a face belongs to a | 163 | # From the profile cache rather than the message: a face belongs to a |
| 152 | # person, not to a line they said, and a profile that arrives after | 164 | # person, not to a line they said, and a profile that arrives after |
| 153 | # their first message should appear on all of them. | 165 | # their first message should appear on all of them. |
| 154 | avatar(avatarFor(senderActor, m.frm), m.frm, size = faceSize, | 166 | avatar(avatarFor(senderActor, m.frm), m.frm, size = faceSize, |
| 155 | onClick = open), | 167 | onClick = open), |
| 156 | - n("button", %*{"label": m.frm, "kind": "plain", "onClick": open})) | 168 | + n("button", %*{"label": m.frm, "kind": "plain", "onClick": open, |
| 169 | + "expand": s.wide})) | ||
| 170 | + if s.wide: row.children.add stretch() | ||
| 157 | if m.at > 0: | 171 | if m.at > 0: |
| 158 | row.children.add dimLabel(clockTime(m.at)) | 172 | row.children.add dimLabel(clockTime(m.at)) |
| 159 | if m.edited: | 173 | if m.edited: |
| @@ -350,9 +364,11 @@ proc chatScreen*(s: State, connected: bool): Node = | |||
| 350 | let name = if room.name.len > 0: room.name else: "Chat" | 364 | let name = if room.name.len > 0: room.name else: "Chat" |
| 351 | let isChannel = room.name.startsWith("#") | 365 | let isChannel = room.name.startsWith("#") |
| 352 | let showUsers = s.showUsers and isChannel | 366 | let showUsers = s.showUsers and isChannel |
| 353 | - # Beside the backlog only where there is room for both. On a narrow window | 367 | + # The chat list rides beside the backlog on a wide window. On a narrow one |
| 354 | - # the panel is the pane, and the backlog stands down for as long as it is up. | 368 | + # there is `← Chats`, which goes to the list as a screen of its own — a |
| 355 | - let narrowPeople = showUsers and not s.wide | 369 | + # panel and a whole-screen list in the same place would be two ways to the |
| 370 | + # same thing, one of them cramped. | ||
| 371 | + let showChatList = s.wide and not s.hideChatList | ||
| 356 | 372 | ||
| 357 | # Wrapping, because on a phone this row asks for more than there is: ← Chats, | 373 | # Wrapping, because on a phone this row asks for more than there is: ← Chats, |
| 358 | # the room's name, People and Overview do not fit across 360 points, and in a | 374 | # the room's name, People and Overview do not fit across 360 points, and in a |
| @@ -393,8 +409,12 @@ proc chatScreen*(s: State, connected: bool): Node = | |||
| 393 | 409 | ||
| 394 | # The backlog. Not a page — a page scrolls everything, which would carry the | 410 | # The backlog. Not a page — a page scrolls everything, which would carry the |
| 395 | # compose bar off the bottom with the messages. | 411 | # compose bar off the bottom with the messages. |
| 396 | - var messages = vbox(%*{"key": "messages", "expand": not narrowPeople}) | 412 | + # The backlog always has the middle, and always expands: a panel coming up |
| 397 | - if not narrowPeople: | 413 | + # beside it takes a strip of the width, not the pane. It used to take the |
| 414 | + # whole of it on a narrow window, so asking who was in a room meant losing | ||
| 415 | + # the room while you looked. | ||
| 416 | + var messages = vbox(%*{"key": "messages", "expand": true}) | ||
| 417 | + block: | ||
| 398 | var sc = scroll(%*{"scrollKey": "messages-" & room.name, | 418 | var sc = scroll(%*{"scrollKey": "messages-" & room.name, |
| 399 | "orientation": "vertical", | 419 | "orientation": "vertical", |
| 400 | "stickToBottom": true, | 420 | "stickToBottom": true, |
| @@ -407,14 +427,42 @@ proc chatScreen*(s: State, connected: bool): Node = | |||
| 407 | sc.children.add dimLabel("Nothing here yet.") | 427 | sc.children.add dimLabel("Nothing here yet.") |
| 408 | messages.children.add sc | 428 | messages.children.add sc |
| 409 | 429 | ||
| 430 | + # The rooms, beside the one being read. Names rather than the cards the | ||
| 431 | + # chats screen uses: a card carries a preview and two buttons and is a | ||
| 432 | + # screen's worth of width, where this is a strip down one side. | ||
| 433 | + # | ||
| 434 | + # Neither strip scrolls, and that is a limit rather than a decision. A | ||
| 435 | + # `scroll` becomes an `Expanded`, and a `vbox` is always `MainAxisSize.min` | ||
| 436 | + # — which is the one combination Flutter will not lay out, and it came back | ||
| 437 | + # as a semantics assertion rather than anything mentioning either. A room | ||
| 438 | + # list or a member list longer than the window will run off the bottom | ||
| 439 | + # until a vbox can be told to fill its parent. | ||
| 440 | + var chatListPane = vbox(%*{"key": "chat-list-pane"}) | ||
| 441 | + if showChatList: | ||
| 442 | + var panel = vbox(%*{"spacing": 4, "widthRequest": sidePanelWidth}, | ||
| 443 | + title2("Chats")) | ||
| 444 | + var list = vbox(%*{"key": "chat-list", "spacing": 4}) | ||
| 445 | + for r in channelList(s.rooms, ""): | ||
| 446 | + let unread = if r.unread > 0: " " & (if r.mention: "◆ " else: "● ") & | ||
| 447 | + $r.unread | ||
| 448 | + else: "" | ||
| 449 | + list.children.add n("button", | ||
| 450 | + %*{"key": "side-" & r.name, "label": r.name & unread, | ||
| 451 | + "kind": (if r.name == s.current: "primary" else: "plain"), | ||
| 452 | + "onClick": "room.open:" & r.name}) | ||
| 453 | + panel.children.add list | ||
| 454 | + chatListPane.children.add panel | ||
| 455 | + | ||
| 410 | var peoplePane = vbox(%*{"key": "people-pane"}) | 456 | var peoplePane = vbox(%*{"key": "people-pane"}) |
| 411 | if showUsers: | 457 | if showUsers: |
| 412 | - var panel = vbox(%*{"spacing": 4, "widthRequest": 150}, | 458 | + var panel = vbox(%*{"spacing": 4, "widthRequest": sidePanelWidth}, |
| 413 | title2("People")) | 459 | title2("People")) |
| 460 | + var list = vbox(%*{"key": "people-list", "spacing": 2}) | ||
| 414 | # Ops first, then alphabetically, with the mode prefix in front of the | 461 | # Ops first, then alphabetically, with the mode prefix in front of the |
| 415 | # name — the order every other client lists them in. | 462 | # name — the order every other client lists them in. |
| 416 | for m in memberList(room.users): | 463 | for m in memberList(room.users): |
| 417 | - panel.children.add label(m.prefix & m.nick) | 464 | + list.children.add label(m.prefix & m.nick) |
| 465 | + panel.children.add list | ||
| 418 | peoplePane.children.add panel | 466 | peoplePane.children.add panel |
| 419 | 467 | ||
| 420 | # Both panels are in wrappers that are always there, for the reason the | 468 | # Both panels are in wrappers that are always there, for the reason the |
| @@ -485,8 +533,10 @@ proc chatScreen*(s: State, connected: bool): Node = | |||
| 485 | # The jump button floats over the backlog rather than taking a row of | 533 | # The jump button floats over the backlog rather than taking a row of |
| 486 | # its own; see `ui.overlay`. | 534 | # its own; see `ui.overlay`. |
| 487 | overlay(%*{"key": "backlog", "expand": true}, | 535 | overlay(%*{"key": "backlog", "expand": true}, |
| 536 | + # The split: the rooms on one side, the people on the other, and the | ||
| 537 | + # conversation between them taking whatever is left. | ||
| 488 | n("hbox", %*{"spacing": 8, "wrap": false, "expand": true}, | 538 | n("hbox", %*{"spacing": 8, "wrap": false, "expand": true}, |
| 489 | - @[messages, peoplePane]), | 539 | + @[chatListPane, messages, peoplePane]), |
| 490 | jump), | 540 | jump), |
| 491 | overview, | 541 | overview, |
| 492 | profile, | 542 | profile, |
modified
nim/src/frq/ui.nim +5 -0 | @@ -102,6 +102,11 @@ func separator*(): Node = n("separator") | ||
| 102 | 102 | |
| 103 | 103 | func spacer*(size: int): Node = n("spacer", %*{"size": size}) |
| 104 | 104 | |
| 105 | +func stretch*(): Node = n("spacer", %*{"expand": true}) | |
| 106 | + ## A gap that takes whatever the row has left, so what follows it sits | |
| 107 | + ## against the far edge. Only inside a row that does not wrap — a `Wrap` | |
| 108 | + ## packs from the left and has no slack to give away. | |
| 109 | + | |
| 105 | 110 | func paragraph*(children: varargs[Node]): Node = |
| 106 | 111 | ## Prose with links in it, wrapping as text rather than as boxes. |
| 107 | 112 | ## |
| @@ -102,6 +102,11 @@ func separator*(): Node = n("separator") | |||
| 102 | 102 | ||
| 103 | func spacer*(size: int): Node = n("spacer", %*{"size": size}) | 103 | func spacer*(size: int): Node = n("spacer", %*{"size": size}) |
| 104 | 104 | ||
| 105 | +func stretch*(): Node = n("spacer", %*{"expand": true}) | ||
| 106 | + ## A gap that takes whatever the row has left, so what follows it sits | ||
| 107 | + ## against the far edge. Only inside a row that does not wrap — a `Wrap` | ||
| 108 | + ## packs from the left and has no slack to give away. | ||
| 109 | + | ||
| 105 | func paragraph*(children: varargs[Node]): Node = | 110 | func paragraph*(children: varargs[Node]): Node = |
| 106 | ## Prose with links in it, wrapping as text rather than as boxes. | 111 | ## Prose with links in it, wrapping as text rather than as boxes. |
| 107 | ## | 112 | ## |
modified
nim/tests/tscreens.nim +84 -0 | @@ -8,6 +8,8 @@ import std/[json, sequtils, strutils, tables, unicode, unittest] | ||
| 8 | 8 | import frq/[ui, cells, model] |
| 9 | 9 | import frq/screens/connect as cs |
| 10 | 10 | import frq/screens/settings as ss |
| 11 | +import frq/screens/chat as cht | |
| 12 | +import frq/[rooms] | |
| 11 | 13 | |
| 12 | 14 | proc find*(node: Node, tag: string): seq[Node] = |
| 13 | 15 | if node.isNil: return |
| @@ -242,3 +244,85 @@ suite "the chats screen": | ||
| 242 | 244 | s.search = "oth" |
| 243 | 245 | let names = ch.chatsScreen(s, true).labels("title-2") |
| 244 | 246 | check names == @["#other"] |
| 247 | + | |
| 248 | +suite "the chat screen's panes": | |
| 249 | + setup: | |
| 250 | + var s = initState() | |
| 251 | + s.formNick = "me" | |
| 252 | + s.windowWidth = wideWidth # both panes on screen | |
| 253 | + s.rooms.ensureRoom("#test") | |
| 254 | + var r = s.rooms["#test"] | |
| 255 | + r.joined = true | |
| 256 | + r.users = {"me": "", "alice": "@"}.toTable | |
| 257 | + r.messages = @[Message(id: "1", frm: "alice", text: "hi", | |
| 258 | + at: 1_700_000_000_000'i64)] | |
| 259 | + s.rooms["#test"] = r | |
| 260 | + s.current = "#test" | |
| 261 | + | |
| 262 | + test "the backlog and the rooms sit side by side when the list is up": | |
| 263 | + # The toggle had a button and rendered nothing at all: `hideChatList` was | |
| 264 | + # read for the button's own highlight and by nobody else. | |
| 265 | + s.hideChatList = false | |
| 266 | + let t = cht.chatScreen(s, true) | |
| 267 | + check "chat-list-pane" in t.keys("vbox") | |
| 268 | + check t.find("vbox").anyIt(it.props{"key"}.getStr() == "chat-list-pane" and | |
| 269 | + it.children.len > 0) | |
| 270 | + check "messages" in t.keys("vbox") | |
| 271 | + | |
| 272 | + test "and the strip goes away when it is folded": | |
| 273 | + s.hideChatList = true | |
| 274 | + let t = cht.chatScreen(s, true) | |
| 275 | + check t.find("vbox").anyIt(it.props{"key"}.getStr() == "chat-list-pane" and | |
| 276 | + it.children.len == 0) | |
| 277 | + | |
| 278 | + test "the people strip is beside the backlog, not instead of it": | |
| 279 | + # It used to take the whole pane on a narrow window, so asking who was in | |
| 280 | + # a room meant losing the room while you looked. | |
| 281 | + s.windowWidth = wideWidth - 1 # one pane at a time | |
| 282 | + s.showUsers = true | |
| 283 | + let t = cht.chatScreen(s, true) | |
| 284 | + check t.find("vbox").anyIt(it.props{"key"}.getStr() == "people-pane" and | |
| 285 | + it.children.len > 0) | |
| 286 | + check t.find("scroll").anyIt( | |
| 287 | + it.props{"scrollKey"}.getStr() == "messages-#test") | |
| 288 | + | |
| 289 | + test "a room in the strip opens it, and the current one is lit": | |
| 290 | + s.hideChatList = false | |
| 291 | + let t = cht.chatScreen(s, true) | |
| 292 | + let side = t.find("button").filterIt( | |
| 293 | + it.props{"key"}.getStr().startsWith("side-")) | |
| 294 | + check side.len == 1 | |
| 295 | + check side[0].props{"onClick"}.getStr() == "room.open:#test" | |
| 296 | + check side[0].props{"kind"}.getStr() == "primary" | |
| 297 | + | |
| 298 | +suite "the sender's row": | |
| 299 | + setup: | |
| 300 | + var s = initState() | |
| 301 | + s.formNick = "me" | |
| 302 | + s.rooms.ensureRoom("#test") | |
| 303 | + var r = s.rooms["#test"] | |
| 304 | + r.messages = @[Message(id: "1", frm: "alice", text: "hi", | |
| 305 | + at: 1_700_000_000_000'i64)] | |
| 306 | + s.rooms["#test"] = r | |
| 307 | + s.current = "#test" | |
| 308 | + | |
| 309 | + test "on a wide window the chips are carried to the right edge": | |
| 310 | + # `align: end` on the chips never did anything: the row was a Wrap, which | |
| 311 | + # packs from the left and has no slack to align with. A stretch in a row | |
| 312 | + # that does not wrap is what moves them. | |
| 313 | + s.windowWidth = wideWidth | |
| 314 | + let t = cht.chatScreen(s, true) | |
| 315 | + check t.find("spacer").anyIt(it.props{"expand"}.getBool()) | |
| 316 | + check t.find("hbox").anyIt(not it.props{"wrap"}.getBool() and | |
| 317 | + it.children.anyIt(it.tag == "avatar")) | |
| 318 | + | |
| 319 | + test "on a narrow one it wraps instead, and nothing is pushed off": | |
| 320 | + # With the name shrunk to nothing the face, the time and three chips | |
| 321 | + # still ask for more than a phone has, so there the row wraps as before. | |
| 322 | + s.windowWidth = wideWidth - 1 # one pane at a time | |
| 323 | + let t = cht.chatScreen(s, true) | |
| 324 | + check not t.find("spacer").anyIt(it.props{"expand"}.getBool()) | |
| 325 | + let senderRows = t.find("hbox").filterIt( | |
| 326 | + it.children.anyIt(it.tag == "avatar")) | |
| 327 | + check senderRows.len == 1 | |
| 328 | + check senderRows[0].props{"wrap"}.getBool() | |
| @@ -8,6 +8,8 @@ import std/[json, sequtils, strutils, tables, unicode, unittest] | |||
| 8 | import frq/[ui, cells, model] | 8 | import frq/[ui, cells, model] |
| 9 | import frq/screens/connect as cs | 9 | import frq/screens/connect as cs |
| 10 | import frq/screens/settings as ss | 10 | import frq/screens/settings as ss |
| 11 | +import frq/screens/chat as cht | ||
| 12 | +import frq/[rooms] | ||
| 11 | 13 | ||
| 12 | proc find*(node: Node, tag: string): seq[Node] = | 14 | proc find*(node: Node, tag: string): seq[Node] = |
| 13 | if node.isNil: return | 15 | if node.isNil: return |
| @@ -242,3 +244,85 @@ suite "the chats screen": | |||
| 242 | s.search = "oth" | 244 | s.search = "oth" |
| 243 | let names = ch.chatsScreen(s, true).labels("title-2") | 245 | let names = ch.chatsScreen(s, true).labels("title-2") |
| 244 | check names == @["#other"] | 246 | check names == @["#other"] |
| 247 | + | ||
| 248 | +suite "the chat screen's panes": | ||
| 249 | + setup: | ||
| 250 | + var s = initState() | ||
| 251 | + s.formNick = "me" | ||
| 252 | + s.windowWidth = wideWidth # both panes on screen | ||
| 253 | + s.rooms.ensureRoom("#test") | ||
| 254 | + var r = s.rooms["#test"] | ||
| 255 | + r.joined = true | ||
| 256 | + r.users = {"me": "", "alice": "@"}.toTable | ||
| 257 | + r.messages = @[Message(id: "1", frm: "alice", text: "hi", | ||
| 258 | + at: 1_700_000_000_000'i64)] | ||
| 259 | + s.rooms["#test"] = r | ||
| 260 | + s.current = "#test" | ||
| 261 | + | ||
| 262 | + test "the backlog and the rooms sit side by side when the list is up": | ||
| 263 | + # The toggle had a button and rendered nothing at all: `hideChatList` was | ||
| 264 | + # read for the button's own highlight and by nobody else. | ||
| 265 | + s.hideChatList = false | ||
| 266 | + let t = cht.chatScreen(s, true) | ||
| 267 | + check "chat-list-pane" in t.keys("vbox") | ||
| 268 | + check t.find("vbox").anyIt(it.props{"key"}.getStr() == "chat-list-pane" and | ||
| 269 | + it.children.len > 0) | ||
| 270 | + check "messages" in t.keys("vbox") | ||
| 271 | + | ||
| 272 | + test "and the strip goes away when it is folded": | ||
| 273 | + s.hideChatList = true | ||
| 274 | + let t = cht.chatScreen(s, true) | ||
| 275 | + check t.find("vbox").anyIt(it.props{"key"}.getStr() == "chat-list-pane" and | ||
| 276 | + it.children.len == 0) | ||
| 277 | + | ||
| 278 | + test "the people strip is beside the backlog, not instead of it": | ||
| 279 | + # It used to take the whole pane on a narrow window, so asking who was in | ||
| 280 | + # a room meant losing the room while you looked. | ||
| 281 | + s.windowWidth = wideWidth - 1 # one pane at a time | ||
| 282 | + s.showUsers = true | ||
| 283 | + let t = cht.chatScreen(s, true) | ||
| 284 | + check t.find("vbox").anyIt(it.props{"key"}.getStr() == "people-pane" and | ||
| 285 | + it.children.len > 0) | ||
| 286 | + check t.find("scroll").anyIt( | ||
| 287 | + it.props{"scrollKey"}.getStr() == "messages-#test") | ||
| 288 | + | ||
| 289 | + test "a room in the strip opens it, and the current one is lit": | ||
| 290 | + s.hideChatList = false | ||
| 291 | + let t = cht.chatScreen(s, true) | ||
| 292 | + let side = t.find("button").filterIt( | ||
| 293 | + it.props{"key"}.getStr().startsWith("side-")) | ||
| 294 | + check side.len == 1 | ||
| 295 | + check side[0].props{"onClick"}.getStr() == "room.open:#test" | ||
| 296 | + check side[0].props{"kind"}.getStr() == "primary" | ||
| 297 | + | ||
| 298 | +suite "the sender's row": | ||
| 299 | + setup: | ||
| 300 | + var s = initState() | ||
| 301 | + s.formNick = "me" | ||
| 302 | + s.rooms.ensureRoom("#test") | ||
| 303 | + var r = s.rooms["#test"] | ||
| 304 | + r.messages = @[Message(id: "1", frm: "alice", text: "hi", | ||
| 305 | + at: 1_700_000_000_000'i64)] | ||
| 306 | + s.rooms["#test"] = r | ||
| 307 | + s.current = "#test" | ||
| 308 | + | ||
| 309 | + test "on a wide window the chips are carried to the right edge": | ||
| 310 | + # `align: end` on the chips never did anything: the row was a Wrap, which | ||
| 311 | + # packs from the left and has no slack to align with. A stretch in a row | ||
| 312 | + # that does not wrap is what moves them. | ||
| 313 | + s.windowWidth = wideWidth | ||
| 314 | + let t = cht.chatScreen(s, true) | ||
| 315 | + check t.find("spacer").anyIt(it.props{"expand"}.getBool()) | ||
| 316 | + check t.find("hbox").anyIt(not it.props{"wrap"}.getBool() and | ||
| 317 | + it.children.anyIt(it.tag == "avatar")) | ||
| 318 | + | ||
| 319 | + test "on a narrow one it wraps instead, and nothing is pushed off": | ||
| 320 | + # With the name shrunk to nothing the face, the time and three chips | ||
| 321 | + # still ask for more than a phone has, so there the row wraps as before. | ||
| 322 | + s.windowWidth = wideWidth - 1 # one pane at a time | ||
| 323 | + let t = cht.chatScreen(s, true) | ||
| 324 | + check not t.find("spacer").anyIt(it.props{"expand"}.getBool()) | ||
| 325 | + let senderRows = t.find("hbox").filterIt( | ||
| 326 | + it.children.anyIt(it.tag == "avatar")) | ||
| 327 | + check senderRows.len == 1 | ||
| 328 | + check senderRows[0].props{"wrap"}.getBool() | ||
modified
nim/tests/tsession.nim +30 -0 | @@ -212,3 +212,33 @@ suite "faces": | ||
| 212 | 212 | say(":irc.freeq.at 352 alice #freeq ~u freeq/key/z6Mkp5we irc.freeq.at " & |
| 213 | 213 | "cartographer H :0 did:key:z6Mkp5wegrxZR62h54HwR329yz7TJ8Ccx4sh") |
| 214 | 214 | check not entry("did:key:z6Mkp5wegrxZR62h54HwR329yz7TJ8Ccx4sh")[1] |
| 215 | + | |
| 216 | +suite "how big the window is": | |
| 217 | + setup: reset() | |
| 218 | + | |
| 219 | + test "the host says, in the value, which is where the renderer puts it": | |
| 220 | + # Nothing sent this until it was noticed: `windowWidth` was zero on every | |
| 221 | + # window there had ever been, so `wide` was always false and the whole | |
| 222 | + # side-by-side layout was unreachable. | |
| 223 | + # | |
| 224 | + # The value and not the id, and this test says so because the first one | |
| 225 | + # did not: it passed against a reducer that read the id only, while the | |
| 226 | + # renderer sent a value nobody looked at. | |
| 227 | + dispatch(%*{"id": "window.size", "value": "1280x760"}) | |
| 228 | + check app.windowWidth == 1280 | |
| 229 | + check app.windowHeight == 760 | |
| 230 | + check app.wide | |
| 231 | + | |
| 232 | + test "or after the colon, for a console or a test that types it": | |
| 233 | + dispatch(%*{"id": "window.size:1280x760"}) | |
| 234 | + check app.wide | |
| 235 | + | |
| 236 | + test "and a narrow one is not wide": | |
| 237 | + dispatch(%*{"id": "window.size", "value": "420x800"}) | |
| 238 | + check not app.wide | |
| 239 | + | |
| 240 | + test "nonsense does not move it": | |
| 241 | + dispatch(%*{"id": "window.size", "value": "1280x760"}) | |
| 242 | + dispatch(%*{"id": "window.size", "value": "banana"}) | |
| 243 | + dispatch(%*{"id": "window.size", "value": "0x0"}) | |
| 244 | + check app.windowWidth == 1280 | |
| @@ -212,3 +212,33 @@ suite "faces": | |||
| 212 | say(":irc.freeq.at 352 alice #freeq ~u freeq/key/z6Mkp5we irc.freeq.at " & | 212 | say(":irc.freeq.at 352 alice #freeq ~u freeq/key/z6Mkp5we irc.freeq.at " & |
| 213 | "cartographer H :0 did:key:z6Mkp5wegrxZR62h54HwR329yz7TJ8Ccx4sh") | 213 | "cartographer H :0 did:key:z6Mkp5wegrxZR62h54HwR329yz7TJ8Ccx4sh") |
| 214 | check not entry("did:key:z6Mkp5wegrxZR62h54HwR329yz7TJ8Ccx4sh")[1] | 214 | check not entry("did:key:z6Mkp5wegrxZR62h54HwR329yz7TJ8Ccx4sh")[1] |
| 215 | + | ||
| 216 | +suite "how big the window is": | ||
| 217 | + setup: reset() | ||
| 218 | + | ||
| 219 | + test "the host says, in the value, which is where the renderer puts it": | ||
| 220 | + # Nothing sent this until it was noticed: `windowWidth` was zero on every | ||
| 221 | + # window there had ever been, so `wide` was always false and the whole | ||
| 222 | + # side-by-side layout was unreachable. | ||
| 223 | + # | ||
| 224 | + # The value and not the id, and this test says so because the first one | ||
| 225 | + # did not: it passed against a reducer that read the id only, while the | ||
| 226 | + # renderer sent a value nobody looked at. | ||
| 227 | + dispatch(%*{"id": "window.size", "value": "1280x760"}) | ||
| 228 | + check app.windowWidth == 1280 | ||
| 229 | + check app.windowHeight == 760 | ||
| 230 | + check app.wide | ||
| 231 | + | ||
| 232 | + test "or after the colon, for a console or a test that types it": | ||
| 233 | + dispatch(%*{"id": "window.size:1280x760"}) | ||
| 234 | + check app.wide | ||
| 235 | + | ||
| 236 | + test "and a narrow one is not wide": | ||
| 237 | + dispatch(%*{"id": "window.size", "value": "420x800"}) | ||
| 238 | + check not app.wide | ||
| 239 | + | ||
| 240 | + test "nonsense does not move it": | ||
| 241 | + dispatch(%*{"id": "window.size", "value": "1280x760"}) | ||
| 242 | + dispatch(%*{"id": "window.size", "value": "banana"}) | ||
| 243 | + dispatch(%*{"id": "window.size", "value": "0x0"}) | ||
| 244 | + check app.windowWidth == 1280 | ||