nandi/frqpublic Fork 0
0e7cd32
Commits
Clone
git clone https://git.rickub.com/nandi/frq.git
git clone ssh://git@rickub.com/nandi/frq.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

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>
nandi committed 2026-09-19T22:39:00-07:00 Browse files
0e7cd32 parent: 23846db
modified flutter/lib/nim_renderer.dart +48 -2
@@ -60,6 +60,11 @@ class _NimAppState extends State<NimApp> {
6060 // switching rooms is a different backlog at a different offset.
6161 final _scrollers = <String, ScrollController>{};
6262
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+
6368 // Where a "go to that message" is pointing, for the one frame it is
6469 // pointing there. The core marks the row with `scrollHere`, this finds it
6570 // after the frame is laid out — `ensureVisible` needs a built element, so
@@ -144,7 +149,31 @@ class _NimAppState extends State<NimApp> {
144149 // faces and reaction pills under here keep their gestures.
145150 home: Scaffold(
146151 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+ ),
148177 ),
149178 );
150179
@@ -456,6 +485,12 @@ class _NimAppState extends State<NimApp> {
456485
457486 case 'spacer':
458487 {
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+ }
459494 final s = _d(n.props['size'], t.spaceXxs);
460495 return SizedBox(width: s, height: s);
461496 }
@@ -507,11 +542,22 @@ class _NimAppState extends State<NimApp> {
507542 // middle of a line and must not look like a control. Text that
508543 // takes a press, with no chrome at all.
509544 if (kind == 'plain') {
510- return InkWell(
545+ final plain = InkWell(
511546 onTap: () => _send(onClick),
512547 child: Text(n.prop('label', ''),
548+ maxLines: 1,
549+ overflow: TextOverflow.ellipsis,
513550 style: _style(t.textBody, t.onBg)),
514551 );
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;
515561 }
516562 if (kind == 'destructive') {
517563 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 is68 // 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 it69 // pointing there. The core marks the row with `scrollHere`, this finds it
65 // after the frame is laid out — `ensureVisible` needs a built element, so70 // 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 that542 // 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) =
438438 of "present.left": app.atPresent = false
439439 of "present.back": app.atPresent = true
440440
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+
441464 # ------------------------------------------------------------ the compose
442465 of "draft.change": app.draft = value
443466 of "send": sendDraft()
@@ -438,6 +438,29 @@ proc dispatch*(event: JsonNode) =
438 of "present.left": app.atPresent = false438 of "present.left": app.atPresent = false
439 of "present.back": app.atPresent = true439 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 compose464 # ------------------------------------------------------------ the compose
442 of "draft.change": app.draft = value465 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
2424 chipGap = 4
2525 overviewLines* = 8
2626
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+
2731 func actionChips(m: Message, mine: bool): Node =
2832 ## Answering and reacting, on the sender's row above the message.
2933 ##
@@ -147,13 +151,23 @@ proc messageBody(s: State, room: Room, m: Message, highlit: bool): Node =
147151 if m.account.len > 0: m.account else: s.dids.getOrDefault(m.frm, ""),
148152 m.frm)
149153 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},
151163 # From the profile cache rather than the message: a face belongs to a
152164 # person, not to a line they said, and a profile that arrives after
153165 # their first message should appear on all of them.
154166 avatar(avatarFor(senderActor, m.frm), m.frm, size = faceSize,
155167 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()
157171 if m.at > 0:
158172 row.children.add dimLabel(clockTime(m.at))
159173 if m.edited:
@@ -350,9 +364,11 @@ proc chatScreen*(s: State, connected: bool): Node =
350364 let name = if room.name.len > 0: room.name else: "Chat"
351365 let isChannel = room.name.startsWith("#")
352366 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
356372
357373 # Wrapping, because on a phone this row asks for more than there is: ← Chats,
358374 # 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 =
393409
394410 # The backlog. Not a page — a page scrolls everything, which would carry the
395411 # 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:
398418 var sc = scroll(%*{"scrollKey": "messages-" & room.name,
399419 "orientation": "vertical",
400420 "stickToBottom": true,
@@ -407,14 +427,42 @@ proc chatScreen*(s: State, connected: bool): Node =
407427 sc.children.add dimLabel("Nothing here yet.")
408428 messages.children.add sc
409429
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+
410456 var peoplePane = vbox(%*{"key": "people-pane"})
411457 if showUsers:
412- var panel = vbox(%*{"spacing": 4, "widthRequest": 150},
458+ var panel = vbox(%*{"spacing": 4, "widthRequest": sidePanelWidth},
413459 title2("People"))
460+ var list = vbox(%*{"key": "people-list", "spacing": 2})
414461 # Ops first, then alphabetically, with the mode prefix in front of the
415462 # name — the order every other client lists them in.
416463 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
418466 peoplePane.children.add panel
419467
420468 # Both panels are in wrappers that are always there, for the reason the
@@ -485,8 +533,10 @@ proc chatScreen*(s: State, connected: bool): Node =
485533 # The jump button floats over the backlog rather than taking a row of
486534 # its own; see `ui.overlay`.
487535 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.
488538 n("hbox", %*{"spacing": 8, "wrap": false, "expand": true},
489- @[messages, peoplePane]),
539+ @[chatListPane, messages, peoplePane]),
490540 jump),
491541 overview,
492542 profile,
@@ -24,6 +24,10 @@ const
24 chipGap = 424 chipGap = 4
25 overviewLines* = 825 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 & ":" & senderActor153 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 a163 # 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 after164 # 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 isChannel366 let showUsers = s.showUsers and isChannel
353- # Beside the backlog only where there is room for both. On a narrow window367+ # 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.wide369+ # 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 a374 # 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 the410 # 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 sc428 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 the461 # 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 panel466 peoplePane.children.add panel
419 467
420 # Both panels are in wrappers that are always there, for the reason the468 # 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 of533 # 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")
102102
103103 func spacer*(size: int): Node = n("spacer", %*{"size": size})
104104
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+
105110 func paragraph*(children: varargs[Node]): Node =
106111 ## Prose with links in it, wrapping as text rather than as boxes.
107112 ##
@@ -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]
88 import frq/[ui, cells, model]
99 import frq/screens/connect as cs
1010 import frq/screens/settings as ss
11+import frq/screens/chat as cht
12+import frq/[rooms]
1113
1214 proc find*(node: Node, tag: string): seq[Node] =
1315 if node.isNil: return
@@ -242,3 +244,85 @@ suite "the chats screen":
242244 s.search = "oth"
243245 let names = ch.chatsScreen(s, true).labels("title-2")
244246 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 cs9 import frq/screens/connect as cs
10 import frq/screens/settings as ss10 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: return15 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":
212212 say(":irc.freeq.at 352 alice #freeq ~u freeq/key/z6Mkp5we irc.freeq.at " &
213213 "cartographer H :0 did:key:z6Mkp5wegrxZR62h54HwR329yz7TJ8Ccx4sh")
214214 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