nandi/frqpublic Fork 0
3a4c0fa
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.

Wake only the rows a change is about

Every message row read the highlight, the jump target, the open picker,
the face under the pointer and the media tick itself, so a change to any
of them re-rendered the whole backlog: a jump, a hover, or any avatar or
picture finishing its fetch. Headless, 3000 messages took 4.3-4.8s to
re-render for each.

Each row now reads a small derived cell instead - is this one highlighted,
is this sender's face on disk - made once per key with glimmer's reaction,
which wakes its readers only when its answer changes. The same changes
re-render 0-2 rows in 0.6-4.5ms.

The face hover card is asked about per message rather than per nick: by
nick, one hover woke every row that person sent and hung a card off each
of their faces. The pictures under a message are realised eagerly so their
reads are recorded.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
nandi committed 2026-09-10T20:03:14-07:00 Browse files
3a4c0fa parent: a0b3031
modified src/frq/app.clj +87 -31
@@ -157,6 +157,46 @@
157157 []
158158 (and @terminal? @terminal-graphics?))
159159
160+(defonce ^:private derived-cells
161+ ;; One cell per question, kept for the session: a cell made afresh on every
162+ ;; render would add a watch to its source each time and never take it off.
163+ (clojure.core/atom {}))
164+
165+(defn- derived
166+ "A reactive cell for one row's answer to a question about shared state, made
167+ once per `k` and kept.
168+
169+ A message row that read `s/highlight` itself was re-rendered whenever the
170+ highlight moved anywhere — every row in the backlog, for one jump — and the
171+ same for a hover, an open picker, or any face or picture finishing a fetch.
172+ A reaction is recomputed on each such change, which is a comparison, but it
173+ wakes the rows that read it only when its answer changes: the two rows a
174+ jump moves between, the one face under the pointer.
175+
176+ Kept rather than collected: glimmer's reactions have no way to unsubscribe,
177+ so a cell per message lasts as long as the session does."
178+ [k f]
179+ (or (get @derived-cells k)
180+ (let [cell (r/reaction (f))]
181+ (swap! derived-cells assoc k cell)
182+ cell)))
183+
184+(defn- avatar-path
185+ "The cell answering where `actor`'s face is on disk, once it is."
186+ [actor]
187+ (derived [:avatar actor] #(do @s/media-tick (avatars/path-when-ready actor))))
188+
189+(defn- image-path
190+ "The cell answering where the picture behind `url` is on disk, once it is."
191+ [url]
192+ (derived [:image url] #(do @s/media-tick (media/path-when-ready url))))
193+
194+(defonce ^:private hovered-face
195+ ;; Which message's face the pointer is on. `profile/hovering` says whose, and
196+ ;; the same person's face is on every message they sent: asked by name alone,
197+ ;; one hover woke every one of those rows and hung a card off each face.
198+ (atom nil))
199+
160200 ;; How big a face is on a message, in points — the size the window has always
161201 ;; drawn one at. A terminal's cell is eight points across and sixteen down, so
162202 ;; the same number is four columns by two rows there: a cached 128-pixel
@@ -562,14 +602,19 @@
562602 than a button with the emoji as its label — the chip draws the glyph from the
563603 Twemoji pack, in colour, where a label gets whatever the text font has."
564604 [channel m]
565- (let [reactions (:reactions m)]
605+ (let [reactions (:reactions m)
606+ ;; Which of this message's pills the pointer is on, if any. Asked once
607+ ;; per message, so moving between pills wakes the rows involved and not
608+ ;; every row that has a reaction on it.
609+ hovered @(derived [:pill-hover (:id m)]
610+ #(let [h @s/reaction-hover]
611+ (when (= (:id m) (:id h)) (:emoji h))))]
566612 ;; `into` and not a lazy `for` inside the vector. The pills read
567- ;; `hovering-reaction?` — a ratom — and a ratom read while a lazy seq is
568- ;; being realised somewhere other than the render is a read the component
569- ;; never records, so the row went on showing what it showed before the
570- ;; pointer arrived. It is the one place in this file whose ratom read is
571- ;; inside the `for` body rather than above it, which is why it is the one
572- ;; place that needs this.
613+ ;; `my-reaction?` — a ratom — and a ratom read while a lazy seq is being
614+ ;; realised somewhere other than the render is a read the component never
615+ ;; records, so the row went on showing what it showed before. The pictures
616+ ;; in `message-body` are the other place a ratom is read inside a `for`,
617+ ;; and are made eager for the same reason.
573618 (into
574619 [:hbox {:key :pills :spacing (chip-gap)}]
575620 (for [emoji (sort (keys reactions))]
@@ -588,7 +633,7 @@
588633 ;; Only the hovered pill carries a card: the panel is painted from
589634 ;; whatever children the node has, and a channel's worth of unseen
590635 ;; lists is a tree nobody looks at.
591- (when (and (platform/desktop?) (s/hovering-reaction? (:id m) emoji))
636+ (when (and (platform/desktop?) (= emoji hovered))
592637 [reactor-card emoji (get reactions emoji)])]))))
593638
594639 (def ^:private picker-columns
@@ -766,7 +811,7 @@
766811 ;; Always an avatar, picture or not: the initial stands in until the
767812 ;; fetch lands, and for the guests who have no profile at all, which
768813 ;; is what keeps the column of faces straight down the left.
769- ;; Reading the tick subscribes this row to a fetch finishing.
814+ ;; `avatar-path` is what wakes this row when that fetch lands.
770815 ;; The face is also the way to the person behind it: Vidya's plain
771816 ;; label does not answer the pointer, so the tap sleek puts on the
772817 ;; nick lives here, on the one thing in the row that does.
@@ -779,18 +824,21 @@
779824 ;; terminal can draw a picture there is a face after all, hung beside
780825 ;; the whole message rather than off its heading — `message-row` has it.
781826 (when-not @terminal?
782- (let [_ @s/media-tick]
827+ (let [src @(avatar-path (:actor m))]
783828 [:avatar (cond-> {:label (:from m)
784- :src (or (avatars/path-when-ready (:actor m)) "")
829+ :src (or src "")
785830 :size face-size
786831 :on-click #(profile/open! (:from m) (:actor m))}
787832 (platform/desktop?)
788- (assoc :on-hover #(profile/hover! (:from m) (:actor m))
833+ (assoc :on-hover #(do (reset! hovered-face (:id m))
834+ (profile/hover! (:from m) (:actor m)))
789835 :on-unhover #(profile/unhover! (:from m))))
790836 ;; Only the hovered face carries one: a card is painted when its
791837 ;; node has children, and the pointer is on one face at a time.
792838 (when (and (platform/desktop?)
793- (= (:from m) (:nick @profile/hovering)))
839+ @(derived [:hovering (:id m) (:from m)]
840+ #(and (= (:id m) @hovered-face)
841+ (= (:from m) (:nick @profile/hovering)))))
794842 [hover-card (:from m) (:actor m)])]))
795843 ;; The name carries the row, so it is set at body size in the plain
796844 ;; text colour: dimmed caption made the one thing you scan a column
@@ -837,7 +885,7 @@
837885 ;; has no msgid, and neither does a closed picker — so `nil = nil` was
838886 ;; every one of those messages opening a picker of its own at startup.
839887 [:vbox {:key :picker :margin-bottom 4}
840- (when (and (:id m) (= (:id m) (:id @s/reacting)))
888+ (when (and (:id m) @(derived [:reacting (:id m)] #(= (:id m) (:id @s/reacting))))
841889 [emoji-picker])]
842890 ;; Pictures under the line that linked them. The link stays: it is what a
843891 ;; failed fetch, an unsupported format, or a phone with no TLS leaves you.
@@ -845,15 +893,17 @@
845893 :images
846894 [:vbox {:key :images :spacing 4}
847895 (when (seq (:images m))
848- ;; Reading the tick is what subscribes this row to a fetch finishing.
849- (let [_ @s/media-tick]
850- (for [url (:images m)]
851- (when-let [path (media/path-when-ready url)]
852- [:image {:key url
853- :src path
854- :max-height (preview-height)
855- :max-width (preview-width)
856- :on-click #(reset! s/lightbox {:path path :url url})}]))))])
896+ ;; Eager, so each read happens during the render and is recorded — see
897+ ;; `reaction-row`. Each picture is read through `image-path`, which
898+ ;; wakes this row for its own pictures landing and not for everyone's.
899+ (doall
900+ (for [url (:images m)]
901+ (when-let [path @(image-path url)]
902+ [:image {:key url
903+ :src path
904+ :max-height (preview-height)
905+ :max-width (preview-width)
906+ :on-click #(reset! s/lightbox {:path path :url url})}]))))])
857907 ;; Reactions go last, under whatever the message turned out to be: a
858908 ;; line with a picture on it is the picture, and pills between the words
859909 ;; and the image they introduce read as reactions to the words alone.
@@ -880,7 +930,12 @@
880930 (let [;; What a jump landed on wears a surface of its own for a moment, so
881931 ;; the answer to "which one was I sent to" is on the screen rather
882932 ;; than in the reader's count of rows.
883- highlit? (boolean (and (:id m) (= (:id m) @s/highlight)))]
933+ ;; Through `derived`, as is everything below that asks about shared
934+ ;; state: a row that read the highlight itself re-rendered for every
935+ ;; jump anywhere in the backlog.
936+ highlit? (boolean (and (:id m)
937+ @(derived [:highlit (:id m)]
938+ #(= (:id m) @s/highlight))))]
884939 [:vbox {:key i :spacing 2 :margin 0
885940 ;; Clear of the right edge: the actions ride that edge, and the
886941 ;; list's scrollbar rides it too — without this the ↩ is what the
@@ -891,7 +946,9 @@
891946 ;; closed-up line to belong to.
892947 :margin-top 10
893948 ;; The jump target is what a "go to message" click scrolls to.
894- :scroll-here (boolean (and (:id m) (= (:id m) @s/jump-to)))}
949+ :scroll-here (boolean (and (:id m)
950+ @(derived [:jump (:id m)]
951+ #(= (:id m) @s/jump-to))))}
895952 ;; The gap above, where the margin cannot be one. `:margin-top 10` is
896953 ;; most of a row in a window and nothing at all in a terminal — ten points
897954 ;; against a row of sixteen, rounded down, because a gap that thin is what
@@ -907,12 +964,11 @@
907964 (if (terminal-face?)
908965 [:hbox {:key :faced :spacing 8}
909966 [:vbox {:key :face :width-request face-size}
910- (let [_ @s/media-tick]
911- (when-let [path (avatars/path-when-ready (:actor m))]
912- [:image {:key :picture
913- :src path
914- :max-width face-size
915- :max-height face-size}]))]
967+ (when-let [path @(avatar-path (:actor m))]
968+ [:image {:key :picture
969+ :src path
970+ :max-width face-size
971+ :max-height face-size}])]
916972 [message-body m highlit?]]
917973 [message-body m highlit?])]))
918974
@@ -157,6 +157,46 @@
157 []157 []
158 (and @terminal? @terminal-graphics?))158 (and @terminal? @terminal-graphics?))
159 159
160+(defonce ^:private derived-cells
161+ ;; One cell per question, kept for the session: a cell made afresh on every
162+ ;; render would add a watch to its source each time and never take it off.
163+ (clojure.core/atom {}))
164+
165+(defn- derived
166+ "A reactive cell for one row's answer to a question about shared state, made
167+ once per `k` and kept.
168+
169+ A message row that read `s/highlight` itself was re-rendered whenever the
170+ highlight moved anywhere — every row in the backlog, for one jump — and the
171+ same for a hover, an open picker, or any face or picture finishing a fetch.
172+ A reaction is recomputed on each such change, which is a comparison, but it
173+ wakes the rows that read it only when its answer changes: the two rows a
174+ jump moves between, the one face under the pointer.
175+
176+ Kept rather than collected: glimmer's reactions have no way to unsubscribe,
177+ so a cell per message lasts as long as the session does."
178+ [k f]
179+ (or (get @derived-cells k)
180+ (let [cell (r/reaction (f))]
181+ (swap! derived-cells assoc k cell)
182+ cell)))
183+
184+(defn- avatar-path
185+ "The cell answering where `actor`'s face is on disk, once it is."
186+ [actor]
187+ (derived [:avatar actor] #(do @s/media-tick (avatars/path-when-ready actor))))
188+
189+(defn- image-path
190+ "The cell answering where the picture behind `url` is on disk, once it is."
191+ [url]
192+ (derived [:image url] #(do @s/media-tick (media/path-when-ready url))))
193+
194+(defonce ^:private hovered-face
195+ ;; Which message's face the pointer is on. `profile/hovering` says whose, and
196+ ;; the same person's face is on every message they sent: asked by name alone,
197+ ;; one hover woke every one of those rows and hung a card off each face.
198+ (atom nil))
199+
160 ;; How big a face is on a message, in points — the size the window has always200 ;; How big a face is on a message, in points — the size the window has always
161 ;; drawn one at. A terminal's cell is eight points across and sixteen down, so201 ;; drawn one at. A terminal's cell is eight points across and sixteen down, so
162 ;; the same number is four columns by two rows there: a cached 128-pixel202 ;; the same number is four columns by two rows there: a cached 128-pixel
@@ -562,14 +602,19 @@
562 than a button with the emoji as its label — the chip draws the glyph from the602 than a button with the emoji as its label — the chip draws the glyph from the
563 Twemoji pack, in colour, where a label gets whatever the text font has."603 Twemoji pack, in colour, where a label gets whatever the text font has."
564 [channel m]604 [channel m]
565- (let [reactions (:reactions m)]605+ (let [reactions (:reactions m)
606+ ;; Which of this message's pills the pointer is on, if any. Asked once
607+ ;; per message, so moving between pills wakes the rows involved and not
608+ ;; every row that has a reaction on it.
609+ hovered @(derived [:pill-hover (:id m)]
610+ #(let [h @s/reaction-hover]
611+ (when (= (:id m) (:id h)) (:emoji h))))]
566 ;; `into` and not a lazy `for` inside the vector. The pills read612 ;; `into` and not a lazy `for` inside the vector. The pills read
567- ;; `hovering-reaction?` — a ratom — and a ratom read while a lazy seq is613+ ;; `my-reaction?` — a ratom — and a ratom read while a lazy seq is being
568- ;; being realised somewhere other than the render is a read the component614+ ;; realised somewhere other than the render is a read the component never
569- ;; never records, so the row went on showing what it showed before the615+ ;; records, so the row went on showing what it showed before. The pictures
570- ;; pointer arrived. It is the one place in this file whose ratom read is616+ ;; in `message-body` are the other place a ratom is read inside a `for`,
571- ;; inside the `for` body rather than above it, which is why it is the one617+ ;; and are made eager for the same reason.
572- ;; place that needs this.
573 (into618 (into
574 [:hbox {:key :pills :spacing (chip-gap)}]619 [:hbox {:key :pills :spacing (chip-gap)}]
575 (for [emoji (sort (keys reactions))]620 (for [emoji (sort (keys reactions))]
@@ -588,7 +633,7 @@
588 ;; Only the hovered pill carries a card: the panel is painted from633 ;; Only the hovered pill carries a card: the panel is painted from
589 ;; whatever children the node has, and a channel's worth of unseen634 ;; whatever children the node has, and a channel's worth of unseen
590 ;; lists is a tree nobody looks at.635 ;; lists is a tree nobody looks at.
591- (when (and (platform/desktop?) (s/hovering-reaction? (:id m) emoji))636+ (when (and (platform/desktop?) (= emoji hovered))
592 [reactor-card emoji (get reactions emoji)])]))))637 [reactor-card emoji (get reactions emoji)])]))))
593 638
594 (def ^:private picker-columns639 (def ^:private picker-columns
@@ -766,7 +811,7 @@
766 ;; Always an avatar, picture or not: the initial stands in until the811 ;; Always an avatar, picture or not: the initial stands in until the
767 ;; fetch lands, and for the guests who have no profile at all, which812 ;; fetch lands, and for the guests who have no profile at all, which
768 ;; is what keeps the column of faces straight down the left.813 ;; is what keeps the column of faces straight down the left.
769- ;; Reading the tick subscribes this row to a fetch finishing.814+ ;; `avatar-path` is what wakes this row when that fetch lands.
770 ;; The face is also the way to the person behind it: Vidya's plain815 ;; The face is also the way to the person behind it: Vidya's plain
771 ;; label does not answer the pointer, so the tap sleek puts on the816 ;; label does not answer the pointer, so the tap sleek puts on the
772 ;; nick lives here, on the one thing in the row that does.817 ;; nick lives here, on the one thing in the row that does.
@@ -779,18 +824,21 @@
779 ;; terminal can draw a picture there is a face after all, hung beside824 ;; terminal can draw a picture there is a face after all, hung beside
780 ;; the whole message rather than off its heading — `message-row` has it.825 ;; the whole message rather than off its heading — `message-row` has it.
781 (when-not @terminal?826 (when-not @terminal?
782- (let [_ @s/media-tick]827+ (let [src @(avatar-path (:actor m))]
783 [:avatar (cond-> {:label (:from m)828 [:avatar (cond-> {:label (:from m)
784- :src (or (avatars/path-when-ready (:actor m)) "")829+ :src (or src "")
785 :size face-size830 :size face-size
786 :on-click #(profile/open! (:from m) (:actor m))}831 :on-click #(profile/open! (:from m) (:actor m))}
787 (platform/desktop?)832 (platform/desktop?)
788- (assoc :on-hover #(profile/hover! (:from m) (:actor m))833+ (assoc :on-hover #(do (reset! hovered-face (:id m))
834+ (profile/hover! (:from m) (:actor m)))
789 :on-unhover #(profile/unhover! (:from m))))835 :on-unhover #(profile/unhover! (:from m))))
790 ;; Only the hovered face carries one: a card is painted when its836 ;; Only the hovered face carries one: a card is painted when its
791 ;; node has children, and the pointer is on one face at a time.837 ;; node has children, and the pointer is on one face at a time.
792 (when (and (platform/desktop?)838 (when (and (platform/desktop?)
793- (= (:from m) (:nick @profile/hovering)))839+ @(derived [:hovering (:id m) (:from m)]
840+ #(and (= (:id m) @hovered-face)
841+ (= (:from m) (:nick @profile/hovering)))))
794 [hover-card (:from m) (:actor m)])]))842 [hover-card (:from m) (:actor m)])]))
795 ;; The name carries the row, so it is set at body size in the plain843 ;; The name carries the row, so it is set at body size in the plain
796 ;; text colour: dimmed caption made the one thing you scan a column844 ;; text colour: dimmed caption made the one thing you scan a column
@@ -837,7 +885,7 @@
837 ;; has no msgid, and neither does a closed picker — so `nil = nil` was885 ;; has no msgid, and neither does a closed picker — so `nil = nil` was
838 ;; every one of those messages opening a picker of its own at startup.886 ;; every one of those messages opening a picker of its own at startup.
839 [:vbox {:key :picker :margin-bottom 4}887 [:vbox {:key :picker :margin-bottom 4}
840- (when (and (:id m) (= (:id m) (:id @s/reacting)))888+ (when (and (:id m) @(derived [:reacting (:id m)] #(= (:id m) (:id @s/reacting))))
841 [emoji-picker])]889 [emoji-picker])]
842 ;; Pictures under the line that linked them. The link stays: it is what a890 ;; Pictures under the line that linked them. The link stays: it is what a
843 ;; failed fetch, an unsupported format, or a phone with no TLS leaves you.891 ;; failed fetch, an unsupported format, or a phone with no TLS leaves you.
@@ -845,15 +893,17 @@
845 :images893 :images
846 [:vbox {:key :images :spacing 4}894 [:vbox {:key :images :spacing 4}
847 (when (seq (:images m))895 (when (seq (:images m))
848- ;; Reading the tick is what subscribes this row to a fetch finishing.896+ ;; Eager, so each read happens during the render and is recorded — see
849- (let [_ @s/media-tick]897+ ;; `reaction-row`. Each picture is read through `image-path`, which
850- (for [url (:images m)]898+ ;; wakes this row for its own pictures landing and not for everyone's.
851- (when-let [path (media/path-when-ready url)]899+ (doall
852- [:image {:key url900+ (for [url (:images m)]
853- :src path901+ (when-let [path @(image-path url)]
854- :max-height (preview-height)902+ [:image {:key url
855- :max-width (preview-width)903+ :src path
856- :on-click #(reset! s/lightbox {:path path :url url})}]))))])904+ :max-height (preview-height)
905+ :max-width (preview-width)
906+ :on-click #(reset! s/lightbox {:path path :url url})}]))))])
857 ;; Reactions go last, under whatever the message turned out to be: a907 ;; Reactions go last, under whatever the message turned out to be: a
858 ;; line with a picture on it is the picture, and pills between the words908 ;; line with a picture on it is the picture, and pills between the words
859 ;; and the image they introduce read as reactions to the words alone.909 ;; and the image they introduce read as reactions to the words alone.
@@ -880,7 +930,12 @@
880 (let [;; What a jump landed on wears a surface of its own for a moment, so930 (let [;; What a jump landed on wears a surface of its own for a moment, so
881 ;; the answer to "which one was I sent to" is on the screen rather931 ;; the answer to "which one was I sent to" is on the screen rather
882 ;; than in the reader's count of rows.932 ;; than in the reader's count of rows.
883- highlit? (boolean (and (:id m) (= (:id m) @s/highlight)))]933+ ;; Through `derived`, as is everything below that asks about shared
934+ ;; state: a row that read the highlight itself re-rendered for every
935+ ;; jump anywhere in the backlog.
936+ highlit? (boolean (and (:id m)
937+ @(derived [:highlit (:id m)]
938+ #(= (:id m) @s/highlight))))]
884 [:vbox {:key i :spacing 2 :margin 0939 [:vbox {:key i :spacing 2 :margin 0
885 ;; Clear of the right edge: the actions ride that edge, and the940 ;; Clear of the right edge: the actions ride that edge, and the
886 ;; list's scrollbar rides it too — without this the ↩ is what the941 ;; list's scrollbar rides it too — without this the ↩ is what the
@@ -891,7 +946,9 @@
891 ;; closed-up line to belong to.946 ;; closed-up line to belong to.
892 :margin-top 10947 :margin-top 10
893 ;; The jump target is what a "go to message" click scrolls to.948 ;; The jump target is what a "go to message" click scrolls to.
894- :scroll-here (boolean (and (:id m) (= (:id m) @s/jump-to)))}949+ :scroll-here (boolean (and (:id m)
950+ @(derived [:jump (:id m)]
951+ #(= (:id m) @s/jump-to))))}
895 ;; The gap above, where the margin cannot be one. `:margin-top 10` is952 ;; The gap above, where the margin cannot be one. `:margin-top 10` is
896 ;; most of a row in a window and nothing at all in a terminal — ten points953 ;; most of a row in a window and nothing at all in a terminal — ten points
897 ;; against a row of sixteen, rounded down, because a gap that thin is what954 ;; against a row of sixteen, rounded down, because a gap that thin is what
@@ -907,12 +964,11 @@
907 (if (terminal-face?)964 (if (terminal-face?)
908 [:hbox {:key :faced :spacing 8}965 [:hbox {:key :faced :spacing 8}
909 [:vbox {:key :face :width-request face-size}966 [:vbox {:key :face :width-request face-size}
910- (let [_ @s/media-tick]967+ (when-let [path @(avatar-path (:actor m))]
911- (when-let [path (avatars/path-when-ready (:actor m))]968+ [:image {:key :picture
912- [:image {:key :picture969+ :src path
913- :src path970+ :max-width face-size
914- :max-width face-size971+ :max-height face-size}])]
915- :max-height face-size}]))]
916 [message-body m highlit?]]972 [message-body m highlit?]]
917 [message-body m highlit?])]))973 [message-body m highlit?])]))
918 974