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

Take a terminal reader back to the newest line

The jump button was in the shared tree already and dead in a terminal: the
backend there emits its own `scroll` event rather than the window's `change`
carrying "end", so `at-present?` never fell and the button never came up, and
`:scroll-to-bottom` is a prop libjolttui does not read, so the tick that asks
the window to go back asked the terminal nothing.

Three answers, all on frq's side. A jump renames the viewport — a sticky
scroll area it has never seen opens at the bottom, which is `:scroll-to-bottom`
said in the words that backend has. Ctrl-End does it without the button, for
hands that are already on the compose bar; the entry keeps plain End for its
own caret, so this one bubbles up unclaimed.

And whether the reader is back is read off the clamp: painting fits an offset
to the content it had and writes the fitted one back, so a request that came
out smaller than it went in ran into the end. That is read in the handler,
which is the one moment it is true of that scroll — the loop paints the frame
the input caused and only then hands out the events. What it cannot see is a
page that lands exactly on the end, which clamps nothing and leaves the button
up for one more press.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
nandi committed 2026-09-03T12:45:03-07:00 Browse files
2705788 parent: 9e3e031
modified src/frq/app.jolt +41 -4
@@ -1237,6 +1237,26 @@
12371237 (for [p people] ^{:key (:nick p)} [member-row p])
12381238 [:dim-label {:label "Nobody listed yet."}])]]))
12391239
1240+(defn- messages-scroll-key
1241+ "What the backlog's scroll position is remembered under.
1242+
1243+ One name in a window: `:scroll-to-bottom` is how the jump button is answered
1244+ there, and the position under that name is the one the reader left behind.
1245+
1246+ The terminal backend has no `:scroll-to-bottom` — a viewport there is moved
1247+ by the wheel and the page keys and by nothing else — but it does open a
1248+ sticky viewport it has never seen at the bottom, which is the same thing
1249+ said differently. So a jump renames the viewport: the tick that asks the
1250+ window to scroll gives the terminal a name with no position saved under it,
1251+ and the newest line is what it opens on.
1252+
1253+ Only on a jump, so scrolling and every message that arrives between two
1254+ jumps still find the position where they left it."
1255+ []
1256+ (if @terminal?
1257+ (str "chat-messages-" @s/jump-tick)
1258+ "chat-messages"))
1259+
12401260 (defn chat-screen []
12411261 (let [name @s/current
12421262 buffer (get @s/channels name)
@@ -1246,7 +1266,14 @@
12461266 ;; that scrolls, bounded so what follows it keeps its room.
12471267 ;; Same margin all round: the compose row's own air is what centres it in
12481268 ;; the strip below the separator, and it is measured from this edge.
1249- [:vbox {:spacing 8 :margin 12}
1269+ ;; Ctrl-End is the jump without the button, for a reader whose hands are
1270+ ;; on the compose bar. It is unhandled everywhere below — the entry takes
1271+ ;; plain End for its own caret and leaves this one alone — so it arrives
1272+ ;; here by bubbling up from whatever had the focus. The window backend
1273+ ;; registers the handler and never calls it: keys there belong to egui.
1274+ [:vbox {:spacing 8 :margin 12
1275+ :on-key (fn [k]
1276+ (when (= k "ctrl+end") (s/jump-to-present!)))}
12501277 [:hbox {:spacing 8}
12511278 ;; The way back to the list, on a window with room for one thing at a
12521279 ;; time. Beside the list there is nothing to go back to, so the button
@@ -1295,11 +1322,15 @@
12951322 [:vbox {:key :messages :fill-height true
12961323 :reserve (below-messages)
12971324 :width-request (if show-users? (messages-width) 0)}
1298- [:scroll {:scroll-key "chat-messages"
1325+ [:scroll {:scroll-key (messages-scroll-key)
12991326 :orientation :vertical
13001327 :stick-to-bottom true
13011328 :scroll-to-bottom @s/jump-tick
1302- :on-change #(reset! s/at-present? (= "end" %))}
1329+ :on-change #(reset! s/at-present? (= "end" %))
1330+ ;; The terminal's half of the same question, which arrives
1331+ ;; as the offset the list moved to rather than as a place.
1332+ ;; `scrolled!` is what turns one into the other.
1333+ :on-scroll s/scrolled!}
13031334 (if (seq (:messages buffer))
13041335 (message-rows (:messages buffer))
13051336 [:dim-label {:label "Nothing here yet."}])]]
@@ -1315,7 +1346,13 @@
13151346 [:vbox {:key :jump}
13161347 (if @s/at-present?
13171348 [:spacer {:size 34}]
1318- [:button {:label "↓ Jump to present" :on-click s/jump-to-present!}])]
1349+ ;; With the key beside it where there is a key: a terminal is where a
1350+ ;; reader is least likely to reach for the mouse, and most likely to
1351+ ;; have paged up here with the keyboard in the first place.
1352+ [:button {:label (if @terminal?
1353+ "↓ Jump to present (Ctrl-End)"
1354+ "↓ Jump to present")
1355+ :on-click s/jump-to-present!}])]
13191356 [:separator {}]
13201357 ;; What the draft is answering, directly above where it is being typed.
13211358 [:vbox {:key :replying}
@@ -1237,6 +1237,26 @@
1237 (for [p people] ^{:key (:nick p)} [member-row p])1237 (for [p people] ^{:key (:nick p)} [member-row p])
1238 [:dim-label {:label "Nobody listed yet."}])]]))1238 [:dim-label {:label "Nobody listed yet."}])]]))
1239 1239
1240+(defn- messages-scroll-key
1241+ "What the backlog's scroll position is remembered under.
1242+
1243+ One name in a window: `:scroll-to-bottom` is how the jump button is answered
1244+ there, and the position under that name is the one the reader left behind.
1245+
1246+ The terminal backend has no `:scroll-to-bottom` — a viewport there is moved
1247+ by the wheel and the page keys and by nothing else — but it does open a
1248+ sticky viewport it has never seen at the bottom, which is the same thing
1249+ said differently. So a jump renames the viewport: the tick that asks the
1250+ window to scroll gives the terminal a name with no position saved under it,
1251+ and the newest line is what it opens on.
1252+
1253+ Only on a jump, so scrolling and every message that arrives between two
1254+ jumps still find the position where they left it."
1255+ []
1256+ (if @terminal?
1257+ (str "chat-messages-" @s/jump-tick)
1258+ "chat-messages"))
1259+
1240 (defn chat-screen []1260 (defn chat-screen []
1241 (let [name @s/current1261 (let [name @s/current
1242 buffer (get @s/channels name)1262 buffer (get @s/channels name)
@@ -1246,7 +1266,14 @@
1246 ;; that scrolls, bounded so what follows it keeps its room.1266 ;; that scrolls, bounded so what follows it keeps its room.
1247 ;; Same margin all round: the compose row's own air is what centres it in1267 ;; Same margin all round: the compose row's own air is what centres it in
1248 ;; the strip below the separator, and it is measured from this edge.1268 ;; the strip below the separator, and it is measured from this edge.
1249- [:vbox {:spacing 8 :margin 12}1269+ ;; Ctrl-End is the jump without the button, for a reader whose hands are
1270+ ;; on the compose bar. It is unhandled everywhere below — the entry takes
1271+ ;; plain End for its own caret and leaves this one alone — so it arrives
1272+ ;; here by bubbling up from whatever had the focus. The window backend
1273+ ;; registers the handler and never calls it: keys there belong to egui.
1274+ [:vbox {:spacing 8 :margin 12
1275+ :on-key (fn [k]
1276+ (when (= k "ctrl+end") (s/jump-to-present!)))}
1250 [:hbox {:spacing 8}1277 [:hbox {:spacing 8}
1251 ;; The way back to the list, on a window with room for one thing at a1278 ;; The way back to the list, on a window with room for one thing at a
1252 ;; time. Beside the list there is nothing to go back to, so the button1279 ;; time. Beside the list there is nothing to go back to, so the button
@@ -1295,11 +1322,15 @@
1295 [:vbox {:key :messages :fill-height true1322 [:vbox {:key :messages :fill-height true
1296 :reserve (below-messages)1323 :reserve (below-messages)
1297 :width-request (if show-users? (messages-width) 0)}1324 :width-request (if show-users? (messages-width) 0)}
1298- [:scroll {:scroll-key "chat-messages"1325+ [:scroll {:scroll-key (messages-scroll-key)
1299 :orientation :vertical1326 :orientation :vertical
1300 :stick-to-bottom true1327 :stick-to-bottom true
1301 :scroll-to-bottom @s/jump-tick1328 :scroll-to-bottom @s/jump-tick
1302- :on-change #(reset! s/at-present? (= "end" %))}1329+ :on-change #(reset! s/at-present? (= "end" %))
1330+ ;; The terminal's half of the same question, which arrives
1331+ ;; as the offset the list moved to rather than as a place.
1332+ ;; `scrolled!` is what turns one into the other.
1333+ :on-scroll s/scrolled!}
1303 (if (seq (:messages buffer))1334 (if (seq (:messages buffer))
1304 (message-rows (:messages buffer))1335 (message-rows (:messages buffer))
1305 [:dim-label {:label "Nothing here yet."}])]]1336 [:dim-label {:label "Nothing here yet."}])]]
@@ -1315,7 +1346,13 @@
1315 [:vbox {:key :jump}1346 [:vbox {:key :jump}
1316 (if @s/at-present?1347 (if @s/at-present?
1317 [:spacer {:size 34}]1348 [:spacer {:size 34}]
1318- [:button {:label "↓ Jump to present" :on-click s/jump-to-present!}])]1349+ ;; With the key beside it where there is a key: a terminal is where a
1350+ ;; reader is least likely to reach for the mouse, and most likely to
1351+ ;; have paged up here with the keyboard in the first place.
1352+ [:button {:label (if @terminal?
1353+ "↓ Jump to present (Ctrl-End)"
1354+ "↓ Jump to present")
1355+ :on-click s/jump-to-present!}])]
1319 [:separator {}]1356 [:separator {}]
1320 ;; What the draft is answering, directly above where it is being typed.1357 ;; What the draft is answering, directly above where it is being typed.
1321 [:vbox {:key :replying}1358 [:vbox {:key :replying}
modified src/frq/state.jolt +31 -1
@@ -140,7 +140,37 @@
140140 (defonce at-present? (atom true))
141141 (defonce jump-tick (atom 0))
142142
143-(defn jump-to-present! [] (swap! jump-tick inc))
143+;; How a backend answers "did that scroll end at the end?".
144+;;
145+;; The window's scroll area says so itself — `:on-change` arrives with "end" —
146+;; and the terminal's does not: it reports the offset it was asked for and
147+;; never how far down the bottom is. What it does do is clamp, so a request
148+;; that came back smaller than it went out is a request that ran into the end.
149+;; Only a backend that can see that installs this; `scrolled!` is written for
150+;; both, and asks.
151+(defonce at-end-probe (atom nil))
152+
153+(defn scrolled!
154+ "A viewport moved under the reader, to the offset `to`.
155+
156+ For the backends whose scroll areas report a position rather than a place:
157+ where that leaves the reader is what `at-end-probe` is asked, and with
158+ nobody to ask, any scroll at all is a scroll away from the newest line."
159+ [to]
160+ (reset! at-present? (boolean (when-let [probe @at-end-probe] (probe to))))
161+ nil)
162+
163+(defn jump-to-present!
164+ "Go back to the newest line.
165+
166+ `at-present?` is set here rather than left to the view because not every
167+ backend can tell us: the window's scroll area reports where it ended up and
168+ corrects this on the next frame, and the terminal's does not report the
169+ bottom at all — so what a jump means for the button that asked for it is
170+ said here, once, for both."
171+ []
172+ (reset! at-present? true)
173+ (swap! jump-tick inc))
144174 ;; A counter rather than a clock: the list only needs their order, and a
145175 ;; monotonic tick cannot be surprised by the system time moving.
146176 (defonce access-tick (atom 0))
@@ -140,7 +140,37 @@
140 (defonce at-present? (atom true))140 (defonce at-present? (atom true))
141 (defonce jump-tick (atom 0))141 (defonce jump-tick (atom 0))
142 142
143-(defn jump-to-present! [] (swap! jump-tick inc))143+;; How a backend answers "did that scroll end at the end?".
144+;;
145+;; The window's scroll area says so itself — `:on-change` arrives with "end" —
146+;; and the terminal's does not: it reports the offset it was asked for and
147+;; never how far down the bottom is. What it does do is clamp, so a request
148+;; that came back smaller than it went out is a request that ran into the end.
149+;; Only a backend that can see that installs this; `scrolled!` is written for
150+;; both, and asks.
151+(defonce at-end-probe (atom nil))
152+
153+(defn scrolled!
154+ "A viewport moved under the reader, to the offset `to`.
155+
156+ For the backends whose scroll areas report a position rather than a place:
157+ where that leaves the reader is what `at-end-probe` is asked, and with
158+ nobody to ask, any scroll at all is a scroll away from the newest line."
159+ [to]
160+ (reset! at-present? (boolean (when-let [probe @at-end-probe] (probe to))))
161+ nil)
162+
163+(defn jump-to-present!
164+ "Go back to the newest line.
165+
166+ `at-present?` is set here rather than left to the view because not every
167+ backend can tell us: the window's scroll area reports where it ended up and
168+ corrects this on the next frame, and the terminal's does not report the
169+ bottom at all — so what a jump means for the button that asked for it is
170+ said here, once, for both."
171+ []
172+ (reset! at-present? true)
173+ (swap! jump-tick inc))
144 ;; A counter rather than a clock: the list only needs their order, and a174 ;; A counter rather than a clock: the list only needs their order, and a
145 ;; monotonic tick cannot be surprised by the system time moving.175 ;; monotonic tick cannot be surprised by the system time moving.
146 (defonce access-tick (atom 0))176 (defonce access-tick (atom 0))
modified src/frq/tui.jolt +26 -0
@@ -35,6 +35,7 @@
3535 [frq.state :as s]
3636 [jolt.host :as host]
3737 [glimmer.core :as ui]
38+ [glimmer-tui.ffi :as tui-ffi]
3839 ;; last, so its install! is the one that stands
3940 [glimmer-tui.core :as tui]))
4041
@@ -107,6 +108,28 @@
107108 (known? (env "TERM_PROGRAM"))
108109 false))))
109110
111+(defn- at-end?
112+ "Whether the scroll that is being reported ran into the end of its content.
113+
114+ A terminal's viewport is moved by an offset and reports the offset it was
115+ asked for, so `to` on its own says nothing about where the bottom is. What
116+ says it is the clamp: painting fits the offset to the content it had and
117+ writes the fitted one back, so a request that came out smaller than it went
118+ in is a request that asked for more list than there was — which is a reader
119+ who has scrolled back down to the newest line.
120+
121+ Read here, in the handler, because this is the one moment it is true of this
122+ scroll: the loop paints the frame the input caused and only then hands out
123+ the events, so the offset on the node is already this request, clamped. The
124+ render that follows will clear it.
125+
126+ The one it cannot see is a page that lands exactly on the end — nothing was
127+ clamped, so it reads as a scroll like any other, and the button stays up for
128+ one more press. There is no max on the node to compare against, and asking
129+ for one is the jump itself."
130+ [to]
131+ (< (tui-ffi/node-get-num (tui-ffi/event-node) "offset") to))
132+
110133 (defn- narrow!
111134 "Tell the layout how much room it has, in the units it expects.
112135
@@ -144,6 +167,9 @@
144167 ;; fetches the picture either way, and Kitty's protocol puts it over the
145168 ;; cells the layout reserved for it.
146169 (reset! app/terminal-graphics? (boolean (graphics?)))
170+ ;; And how a scroll here answers "am I at the newest line?", which the
171+ ;; window's scroll area answers for itself.
172+ (reset! s/at-end-probe at-end?)
147173 (app/start! {:after! tui/after!
148174 :every! tui/every!
149175 :title! nil
@@ -35,6 +35,7 @@
35 [frq.state :as s]35 [frq.state :as s]
36 [jolt.host :as host]36 [jolt.host :as host]
37 [glimmer.core :as ui]37 [glimmer.core :as ui]
38+ [glimmer-tui.ffi :as tui-ffi]
38 ;; last, so its install! is the one that stands39 ;; last, so its install! is the one that stands
39 [glimmer-tui.core :as tui]))40 [glimmer-tui.core :as tui]))
40 41
@@ -107,6 +108,28 @@
107 (known? (env "TERM_PROGRAM"))108 (known? (env "TERM_PROGRAM"))
108 false))))109 false))))
109 110
111+(defn- at-end?
112+ "Whether the scroll that is being reported ran into the end of its content.
113+
114+ A terminal's viewport is moved by an offset and reports the offset it was
115+ asked for, so `to` on its own says nothing about where the bottom is. What
116+ says it is the clamp: painting fits the offset to the content it had and
117+ writes the fitted one back, so a request that came out smaller than it went
118+ in is a request that asked for more list than there was — which is a reader
119+ who has scrolled back down to the newest line.
120+
121+ Read here, in the handler, because this is the one moment it is true of this
122+ scroll: the loop paints the frame the input caused and only then hands out
123+ the events, so the offset on the node is already this request, clamped. The
124+ render that follows will clear it.
125+
126+ The one it cannot see is a page that lands exactly on the end — nothing was
127+ clamped, so it reads as a scroll like any other, and the button stays up for
128+ one more press. There is no max on the node to compare against, and asking
129+ for one is the jump itself."
130+ [to]
131+ (< (tui-ffi/node-get-num (tui-ffi/event-node) "offset") to))
132+
110 (defn- narrow!133 (defn- narrow!
111 "Tell the layout how much room it has, in the units it expects.134 "Tell the layout how much room it has, in the units it expects.
112 135
@@ -144,6 +167,9 @@
144 ;; fetches the picture either way, and Kitty's protocol puts it over the167 ;; fetches the picture either way, and Kitty's protocol puts it over the
145 ;; cells the layout reserved for it.168 ;; cells the layout reserved for it.
146 (reset! app/terminal-graphics? (boolean (graphics?)))169 (reset! app/terminal-graphics? (boolean (graphics?)))
170+ ;; And how a scroll here answers "am I at the newest line?", which the
171+ ;; window's scroll area answers for itself.
172+ (reset! s/at-end-probe at-end?)
147 (app/start! {:after! tui/after!173 (app/start! {:after! tui/after!
148 :every! tui/every!174 :every! tui/every!
149 :title! nil175 :title! nil