Show the list and the conversation at once on a wide window
The app is laid out for a phone: the list and the chat take turns, and a desktop window gets the same single column with margins either side. Past 900pt there is room for both, so the chats screen becomes two panes — the list at 320, the conversation in what is left — and opening a channel there is a change of `current` rather than a change of screen. Discover and settings stay whole screens: they are somewhere else, not the other half of here. Nothing reported a window's size, so the backend writes it onto the window node the way an entry writes back its text, and a timer polls it into a cell. A poll rather than an event: only what a component derefs re-renders, so the layout follows a drag of the window's edge without every frame touching the tree. Two things the panes needed from vidya. `:fill-height`, because a column in a row is otherwise as tall as the row — one button, at the moment it is placed — and both the list and the backlog size themselves against the height they are handed. And `:wrap false`, because an `:hbox` wraps: the conversation asking for a few points more than were left was painted on a row below the list, off the bottom of the window, which reads exactly like a conversation that has gone missing. So only the list is given a width now, and the conversation takes the remainder rather than the window's width minus a guess at the list's. The glimmer pin moves for the other half of that bug: replacing a native node with one of another tag left every watcher under it subscribed, so swapping the layouts shredded the chat — one component's props painted onto another's widget. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
10a1bd0 parent: 3c98eb0 modified
deps.edn +6 -2 | @@ -2,12 +2,16 @@ | ||
| 2 | 2 | |
| 3 | 3 | ;; glimmer owns the reactive half (ratom, components, reconciler); |
| 4 | 4 | ;; glimmer-vidya paints it as Vidya/egui through libvidya's retained-tree ABI. |
| 5 | - ;; Our fork of glimmer, until the reconciler fix in it lands upstream: a | |
| 5 | + ;; Our fork of glimmer, until the reconciler fixes in it land upstream. A | |
| 6 | 6 | ;; component unmounted between a cell firing and the queued render running |
| 7 | 7 | ;; used to render anyway, into widgets the backend had already freed and |
| 8 | 8 | ;; handed out again — which took the message list apart in a busy channel. |
| 9 | + ;; And replacing a native node with one of another tag left every watcher | |
| 10 | + ;; under it subscribed, so swapping the phone layout for the split one and | |
| 11 | + ;; back shredded the chat: one component's props painted onto another's | |
| 12 | + ;; widget. | |
| 9 | 13 | :deps {jolt-lang/glimmer {:git/url "https://gitlab.com/nandithebull/glimmer" |
| 10 | - :git/sha "4e733aa26343cef0478d8dfd3f2b76c0113b0969"} | |
| 14 | + :git/sha "399df371c790d690fb6e4560c3d4d7f838502857"} | |
| 11 | 15 | nandi/glimmer-vidya {:local/root "../vidya/glimmer"}} |
| 12 | 16 | |
| 13 | 17 | ;; libvidya, the Rust/egui build (the C/raylib one does not export the tree |
| @@ -2,12 +2,16 @@ | |||
| 2 | 2 | ||
| 3 | ;; glimmer owns the reactive half (ratom, components, reconciler); | 3 | ;; glimmer owns the reactive half (ratom, components, reconciler); |
| 4 | ;; glimmer-vidya paints it as Vidya/egui through libvidya's retained-tree ABI. | 4 | ;; glimmer-vidya paints it as Vidya/egui through libvidya's retained-tree ABI. |
| 5 | - ;; Our fork of glimmer, until the reconciler fix in it lands upstream: a | 5 | + ;; Our fork of glimmer, until the reconciler fixes in it land upstream. A |
| 6 | ;; component unmounted between a cell firing and the queued render running | 6 | ;; component unmounted between a cell firing and the queued render running |
| 7 | ;; used to render anyway, into widgets the backend had already freed and | 7 | ;; used to render anyway, into widgets the backend had already freed and |
| 8 | ;; handed out again — which took the message list apart in a busy channel. | 8 | ;; handed out again — which took the message list apart in a busy channel. |
| 9 | + ;; And replacing a native node with one of another tag left every watcher | ||
| 10 | + ;; under it subscribed, so swapping the phone layout for the split one and | ||
| 11 | + ;; back shredded the chat: one component's props painted onto another's | ||
| 12 | + ;; widget. | ||
| 9 | :deps {jolt-lang/glimmer {:git/url "https://gitlab.com/nandithebull/glimmer" | 13 | :deps {jolt-lang/glimmer {:git/url "https://gitlab.com/nandithebull/glimmer" |
| 10 | - :git/sha "4e733aa26343cef0478d8dfd3f2b76c0113b0969"} | 14 | + :git/sha "399df371c790d690fb6e4560c3d4d7f838502857"} |
| 11 | nandi/glimmer-vidya {:local/root "../vidya/glimmer"}} | 15 | nandi/glimmer-vidya {:local/root "../vidya/glimmer"}} |
| 12 | 16 | ||
| 13 | ;; libvidya, the Rust/egui build (the C/raylib one does not export the tree | 17 | ;; libvidya, the Rust/egui build (the C/raylib one does not export the tree |
modified
src/frq/app.jolt +67 -9 | @@ -529,7 +529,13 @@ | ||
| 529 | 529 | ;; bottom of a screen wants is above it, not beneath it. |
| 530 | 530 | [:vbox {:spacing 8 :margin 12 :margin-bottom 4} |
| 531 | 531 | [:hbox {:spacing 8} |
| 532 | - [:button {:label "← Chats" :on-click #(reset! s/screen :chats)}] | |
| 532 | + ;; The way back to the list, on a window with room for one thing at a | |
| 533 | + ;; time. Beside the list there is nothing to go back to, so the button | |
| 534 | + ;; goes — in a wrapper of its own, since a child that comes and goes | |
| 535 | + ;; would otherwise renumber the row for the reconciler. | |
| 536 | + [:vbox {:key :back} | |
| 537 | + (when-not (s/wide?) | |
| 538 | + [:button {:label "← Chats" :on-click #(reset! s/screen :chats)}])] | |
| 533 | 539 | [:title {:label (or name "Chat")}]] |
| 534 | 540 | [error-note] |
| 535 | 541 | ;; :reserve leaves room for everything below: the jump button's row, the |
| @@ -602,6 +608,46 @@ | ||
| 602 | 608 | :on-activate s/send-draft!}] |
| 603 | 609 | [:button {:label "Send" :kind :primary :on-click s/send-draft!}]]])) |
| 604 | 610 | |
| 611 | +;; ---------------------------------------------------------------- split | |
| 612 | + | |
| 613 | +(def ^:private sidebar-width 320) | |
| 614 | + | |
| 615 | +(defn- no-chat-pane | |
| 616 | + "What fills the second pane before a conversation has been picked. The pane | |
| 617 | + is there either way — a list that widened into two columns and back again as | |
| 618 | + channels were opened would be a worse answer than an empty half." | |
| 619 | + [] | |
| 620 | + [:vbox {:spacing 8 :margin 12} | |
| 621 | + [:title {:label "frq"}] | |
| 622 | + [:card {} [:dim-label {:label "Pick a conversation on the left."}]]]) | |
| 623 | + | |
| 624 | +(defn split-screen | |
| 625 | + "The chats list and the conversation side by side, for a window wide enough | |
| 626 | + to hold both. | |
| 627 | + | |
| 628 | + Same components as the narrow layout, in a row instead of one at a time: the | |
| 629 | + list keeps its own scroll and its own tab bar, and the conversation keeps the | |
| 630 | + compose bar pinned under a backlog that scrolls on its own. | |
| 631 | + | |
| 632 | + Both panes take `:fill-height`: a column in a row is otherwise as tall as the | |
| 633 | + row, which at the moment it is placed is one button — and the list and the | |
| 634 | + message backlog both size themselves against the height they are handed. | |
| 635 | + | |
| 636 | + Only the list is given a width. The conversation takes what is left, rather | |
| 637 | + than the window's width minus the list's: the list costs a little more than | |
| 638 | + its 320 (its page has padding of its own), and a second column asking for | |
| 639 | + more than remains is wrapped onto a row below — painting the whole | |
| 640 | + conversation off the bottom of the window, which reads exactly like a | |
| 641 | + conversation that has gone missing." | |
| 642 | + [] | |
| 643 | + [:hbox {:spacing 0 :wrap false} | |
| 644 | + [:vbox {:key :list :width-request sidebar-width :fill-height true} | |
| 645 | + [chats-screen]] | |
| 646 | + [:vbox {:key :chat :fill-height true} | |
| 647 | + (if @s/current | |
| 648 | + [chat-screen] | |
| 649 | + [no-chat-pane])]]) | |
| 650 | + | |
| 605 | 651 | ;; ---------------------------------------------------------------- discover |
| 606 | 652 | |
| 607 | 653 | (defn discover-screen [] |
| @@ -658,14 +704,19 @@ | ||
| 658 | 704 | ;; ---------------------------------------------------------------- shell |
| 659 | 705 | |
| 660 | 706 | (defn app [] |
| 661 | - (if @s/lightbox | |
| 662 | - [lightbox-screen] | |
| 663 | - (case @s/screen | |
| 664 | - :connect [connect-screen] | |
| 665 | - :chat [chat-screen] | |
| 666 | - :discover [discover-screen] | |
| 667 | - :settings [settings-screen] | |
| 668 | - [chats-screen]))) | |
| 707 | + (cond | |
| 708 | + @s/lightbox [lightbox-screen] | |
| 709 | + ;; Wide enough for both, and on one of the two screens that are halves of | |
| 710 | + ;; the same thing: the list and the conversation it opens. Discover and | |
| 711 | + ;; settings stay whole screens — they are somewhere else, not the other | |
| 712 | + ;; half of here. | |
| 713 | + (and (s/wide?) (contains? #{:chats :chat} @s/screen)) [split-screen] | |
| 714 | + :else (case @s/screen | |
| 715 | + :connect [connect-screen] | |
| 716 | + :chat [chat-screen] | |
| 717 | + :discover [discover-screen] | |
| 718 | + :settings [settings-screen] | |
| 719 | + [chats-screen]))) | |
| 669 | 720 | |
| 670 | 721 | (defn -main [& _] |
| 671 | 722 | ;; Before the window: the rooms this client has been in, and a saved sign-in |
| @@ -679,4 +730,11 @@ | ||
| 679 | 730 | ;; user should be looking at while this happens, and if it fails, the |
| 680 | 731 | ;; error lands somewhere visible. |
| 681 | 732 | (vidya/after! 150 s/connect!)) |
| 733 | + ;; The window's width, into a ratom, a few times a second. Polled rather than | |
| 734 | + ;; delivered: the backend reports a size by writing it onto the window node, | |
| 735 | + ;; and only what a component derefs re-renders — so the layout follows a drag | |
| 736 | + ;; of the window's edge without every frame touching the tree. | |
| 737 | + (vidya/every! 200 #(let [w (vidya/window-width)] | |
| 738 | + (when (not= w @s/window-width) | |
| 739 | + (reset! s/window-width w)))) | |
| 682 | 740 | (ui/run app :title "frq" :width 520 :height 860)) |
| @@ -529,7 +529,13 @@ | |||
| 529 | ;; bottom of a screen wants is above it, not beneath it. | 529 | ;; bottom of a screen wants is above it, not beneath it. |
| 530 | [:vbox {:spacing 8 :margin 12 :margin-bottom 4} | 530 | [:vbox {:spacing 8 :margin 12 :margin-bottom 4} |
| 531 | [:hbox {:spacing 8} | 531 | [:hbox {:spacing 8} |
| 532 | - [:button {:label "← Chats" :on-click #(reset! s/screen :chats)}] | 532 | + ;; The way back to the list, on a window with room for one thing at a |
| 533 | + ;; time. Beside the list there is nothing to go back to, so the button | ||
| 534 | + ;; goes — in a wrapper of its own, since a child that comes and goes | ||
| 535 | + ;; would otherwise renumber the row for the reconciler. | ||
| 536 | + [:vbox {:key :back} | ||
| 537 | + (when-not (s/wide?) | ||
| 538 | + [:button {:label "← Chats" :on-click #(reset! s/screen :chats)}])] | ||
| 533 | [:title {:label (or name "Chat")}]] | 539 | [:title {:label (or name "Chat")}]] |
| 534 | [error-note] | 540 | [error-note] |
| 535 | ;; :reserve leaves room for everything below: the jump button's row, the | 541 | ;; :reserve leaves room for everything below: the jump button's row, the |
| @@ -602,6 +608,46 @@ | |||
| 602 | :on-activate s/send-draft!}] | 608 | :on-activate s/send-draft!}] |
| 603 | [:button {:label "Send" :kind :primary :on-click s/send-draft!}]]])) | 609 | [:button {:label "Send" :kind :primary :on-click s/send-draft!}]]])) |
| 604 | 610 | ||
| 611 | +;; ---------------------------------------------------------------- split | ||
| 612 | + | ||
| 613 | +(def ^:private sidebar-width 320) | ||
| 614 | + | ||
| 615 | +(defn- no-chat-pane | ||
| 616 | + "What fills the second pane before a conversation has been picked. The pane | ||
| 617 | + is there either way — a list that widened into two columns and back again as | ||
| 618 | + channels were opened would be a worse answer than an empty half." | ||
| 619 | + [] | ||
| 620 | + [:vbox {:spacing 8 :margin 12} | ||
| 621 | + [:title {:label "frq"}] | ||
| 622 | + [:card {} [:dim-label {:label "Pick a conversation on the left."}]]]) | ||
| 623 | + | ||
| 624 | +(defn split-screen | ||
| 625 | + "The chats list and the conversation side by side, for a window wide enough | ||
| 626 | + to hold both. | ||
| 627 | + | ||
| 628 | + Same components as the narrow layout, in a row instead of one at a time: the | ||
| 629 | + list keeps its own scroll and its own tab bar, and the conversation keeps the | ||
| 630 | + compose bar pinned under a backlog that scrolls on its own. | ||
| 631 | + | ||
| 632 | + Both panes take `:fill-height`: a column in a row is otherwise as tall as the | ||
| 633 | + row, which at the moment it is placed is one button — and the list and the | ||
| 634 | + message backlog both size themselves against the height they are handed. | ||
| 635 | + | ||
| 636 | + Only the list is given a width. The conversation takes what is left, rather | ||
| 637 | + than the window's width minus the list's: the list costs a little more than | ||
| 638 | + its 320 (its page has padding of its own), and a second column asking for | ||
| 639 | + more than remains is wrapped onto a row below — painting the whole | ||
| 640 | + conversation off the bottom of the window, which reads exactly like a | ||
| 641 | + conversation that has gone missing." | ||
| 642 | + [] | ||
| 643 | + [:hbox {:spacing 0 :wrap false} | ||
| 644 | + [:vbox {:key :list :width-request sidebar-width :fill-height true} | ||
| 645 | + [chats-screen]] | ||
| 646 | + [:vbox {:key :chat :fill-height true} | ||
| 647 | + (if @s/current | ||
| 648 | + [chat-screen] | ||
| 649 | + [no-chat-pane])]]) | ||
| 650 | + | ||
| 605 | ;; ---------------------------------------------------------------- discover | 651 | ;; ---------------------------------------------------------------- discover |
| 606 | 652 | ||
| 607 | (defn discover-screen [] | 653 | (defn discover-screen [] |
| @@ -658,14 +704,19 @@ | |||
| 658 | ;; ---------------------------------------------------------------- shell | 704 | ;; ---------------------------------------------------------------- shell |
| 659 | 705 | ||
| 660 | (defn app [] | 706 | (defn app [] |
| 661 | - (if @s/lightbox | 707 | + (cond |
| 662 | - [lightbox-screen] | 708 | + @s/lightbox [lightbox-screen] |
| 663 | - (case @s/screen | 709 | + ;; Wide enough for both, and on one of the two screens that are halves of |
| 664 | - :connect [connect-screen] | 710 | + ;; the same thing: the list and the conversation it opens. Discover and |
| 665 | - :chat [chat-screen] | 711 | + ;; settings stay whole screens — they are somewhere else, not the other |
| 666 | - :discover [discover-screen] | 712 | + ;; half of here. |
| 667 | - :settings [settings-screen] | 713 | + (and (s/wide?) (contains? #{:chats :chat} @s/screen)) [split-screen] |
| 668 | - [chats-screen]))) | 714 | + :else (case @s/screen |
| 715 | + :connect [connect-screen] | ||
| 716 | + :chat [chat-screen] | ||
| 717 | + :discover [discover-screen] | ||
| 718 | + :settings [settings-screen] | ||
| 719 | + [chats-screen]))) | ||
| 669 | 720 | ||
| 670 | (defn -main [& _] | 721 | (defn -main [& _] |
| 671 | ;; Before the window: the rooms this client has been in, and a saved sign-in | 722 | ;; Before the window: the rooms this client has been in, and a saved sign-in |
| @@ -679,4 +730,11 @@ | |||
| 679 | ;; user should be looking at while this happens, and if it fails, the | 730 | ;; user should be looking at while this happens, and if it fails, the |
| 680 | ;; error lands somewhere visible. | 731 | ;; error lands somewhere visible. |
| 681 | (vidya/after! 150 s/connect!)) | 732 | (vidya/after! 150 s/connect!)) |
| 733 | + ;; The window's width, into a ratom, a few times a second. Polled rather than | ||
| 734 | + ;; delivered: the backend reports a size by writing it onto the window node, | ||
| 735 | + ;; and only what a component derefs re-renders — so the layout follows a drag | ||
| 736 | + ;; of the window's edge without every frame touching the tree. | ||
| 737 | + (vidya/every! 200 #(let [w (vidya/window-width)] | ||
| 738 | + (when (not= w @s/window-width) | ||
| 739 | + (reset! s/window-width w)))) | ||
| 682 | (ui/run app :title "frq" :width 520 :height 860)) | 740 | (ui/run app :title "frq" :width 520 :height 860)) |
modified
src/frq/state.jolt +31 -2 | @@ -88,6 +88,32 @@ | ||
| 88 | 88 | (defonce lightbox (atom nil)) ; {:path :url} |
| 89 | 89 | (defonce join-input (atom "")) |
| 90 | 90 | |
| 91 | +;; The window's content width in points, polled from the backend a few times a | |
| 92 | +;; second. The app is laid out for a phone-width window, and this is what lets | |
| 93 | +;; a wide one be more than a phone with margins: past `wide-width` the channel | |
| 94 | +;; list and the conversation are both on screen instead of taking turns. | |
| 95 | +(defonce window-width (atom 0)) | |
| 96 | + | |
| 97 | +;; Where the second pane starts paying for itself. Below this a 300pt list | |
| 98 | +;; beside a conversation leaves the messages narrower than the phone layout | |
| 99 | +;; they were written for. | |
| 100 | +(def wide-width 900) | |
| 101 | + | |
| 102 | +(defn wide? | |
| 103 | + "True while the window has room for the list and a conversation at once." | |
| 104 | + [] | |
| 105 | + (>= @window-width wide-width)) | |
| 106 | + | |
| 107 | +(defn chat-visible? | |
| 108 | + "Whether the conversation in `current` is on screen. | |
| 109 | + | |
| 110 | + On a narrow window that is the chat screen alone. On a wide one the chats | |
| 111 | + screen shows it too, in the pane beside the list — so this, and not the | |
| 112 | + screen, is what decides whether an arriving line counts as unread." | |
| 113 | + [] | |
| 114 | + (or (= :chat @screen) | |
| 115 | + (and (wide?) (= :chats @screen)))) | |
| 116 | + | |
| 91 | 117 | ;; Whether the chat view is showing the newest line, and a counter the view |
| 92 | 118 | ;; watches to be told to go back to it. A counter rather than a flag: a flag |
| 93 | 119 | ;; would need clearing, and there is no frame in which to clear it. |
| @@ -150,7 +176,7 @@ | ||
| 150 | 176 | (swap! channels |
| 151 | 177 | (fn [m] |
| 152 | 178 | (let [m (ensure-channel m channel) |
| 153 | - viewing? (and (= :chat @screen) (= channel @current))] | |
| 179 | + viewing? (and (chat-visible?) (= channel @current))] | |
| 154 | 180 | (-> m |
| 155 | 181 | (update-in [channel :messages] conj |
| 156 | 182 | {:from from :text text :system? (= "*" from) |
| @@ -170,7 +196,10 @@ | ||
| 170 | 196 | buffer stays), and opening one is a request to be in it." |
| 171 | 197 | [name] |
| 172 | 198 | (reset! current name) |
| 173 | - (reset! screen :chat) | |
| 199 | + ;; On a wide window the conversation lives in the chats screen's second | |
| 200 | + ;; pane, beside the list; :chat is the narrow window's way of showing it | |
| 201 | + ;; instead of the list, and there is nothing there to trade it for. | |
| 202 | + (reset! screen (if (wide?) :chats :chat)) | |
| 174 | 203 | ;; A picker belongs to the message it was opened on; carrying it into another |
| 175 | 204 | ;; buffer would offer to react to something that is no longer on screen. |
| 176 | 205 | (reset! reacting nil) |
| @@ -88,6 +88,32 @@ | |||
| 88 | (defonce lightbox (atom nil)) ; {:path :url} | 88 | (defonce lightbox (atom nil)) ; {:path :url} |
| 89 | (defonce join-input (atom "")) | 89 | (defonce join-input (atom "")) |
| 90 | 90 | ||
| 91 | +;; The window's content width in points, polled from the backend a few times a | ||
| 92 | +;; second. The app is laid out for a phone-width window, and this is what lets | ||
| 93 | +;; a wide one be more than a phone with margins: past `wide-width` the channel | ||
| 94 | +;; list and the conversation are both on screen instead of taking turns. | ||
| 95 | +(defonce window-width (atom 0)) | ||
| 96 | + | ||
| 97 | +;; Where the second pane starts paying for itself. Below this a 300pt list | ||
| 98 | +;; beside a conversation leaves the messages narrower than the phone layout | ||
| 99 | +;; they were written for. | ||
| 100 | +(def wide-width 900) | ||
| 101 | + | ||
| 102 | +(defn wide? | ||
| 103 | + "True while the window has room for the list and a conversation at once." | ||
| 104 | + [] | ||
| 105 | + (>= @window-width wide-width)) | ||
| 106 | + | ||
| 107 | +(defn chat-visible? | ||
| 108 | + "Whether the conversation in `current` is on screen. | ||
| 109 | + | ||
| 110 | + On a narrow window that is the chat screen alone. On a wide one the chats | ||
| 111 | + screen shows it too, in the pane beside the list — so this, and not the | ||
| 112 | + screen, is what decides whether an arriving line counts as unread." | ||
| 113 | + [] | ||
| 114 | + (or (= :chat @screen) | ||
| 115 | + (and (wide?) (= :chats @screen)))) | ||
| 116 | + | ||
| 91 | ;; Whether the chat view is showing the newest line, and a counter the view | 117 | ;; Whether the chat view is showing the newest line, and a counter the view |
| 92 | ;; watches to be told to go back to it. A counter rather than a flag: a flag | 118 | ;; watches to be told to go back to it. A counter rather than a flag: a flag |
| 93 | ;; would need clearing, and there is no frame in which to clear it. | 119 | ;; would need clearing, and there is no frame in which to clear it. |
| @@ -150,7 +176,7 @@ | |||
| 150 | (swap! channels | 176 | (swap! channels |
| 151 | (fn [m] | 177 | (fn [m] |
| 152 | (let [m (ensure-channel m channel) | 178 | (let [m (ensure-channel m channel) |
| 153 | - viewing? (and (= :chat @screen) (= channel @current))] | 179 | + viewing? (and (chat-visible?) (= channel @current))] |
| 154 | (-> m | 180 | (-> m |
| 155 | (update-in [channel :messages] conj | 181 | (update-in [channel :messages] conj |
| 156 | {:from from :text text :system? (= "*" from) | 182 | {:from from :text text :system? (= "*" from) |
| @@ -170,7 +196,10 @@ | |||
| 170 | buffer stays), and opening one is a request to be in it." | 196 | buffer stays), and opening one is a request to be in it." |
| 171 | [name] | 197 | [name] |
| 172 | (reset! current name) | 198 | (reset! current name) |
| 173 | - (reset! screen :chat) | 199 | + ;; On a wide window the conversation lives in the chats screen's second |
| 200 | + ;; pane, beside the list; :chat is the narrow window's way of showing it | ||
| 201 | + ;; instead of the list, and there is nothing there to trade it for. | ||
| 202 | + (reset! screen (if (wide?) :chats :chat)) | ||
| 174 | ;; A picker belongs to the message it was opened on; carrying it into another | 203 | ;; A picker belongs to the message it was opened on; carrying it into another |
| 175 | ;; buffer would offer to react to something that is no longer on screen. | 204 | ;; buffer would offer to react to something that is no longer on screen. |
| 176 | (reset! reacting nil) | 205 | (reset! reacting nil) |