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>
2705788 parent: 9e3e031 modified
src/frq/app.jolt +41 -4 | @@ -1237,6 +1237,26 @@ | ||
| 1237 | 1237 | (for [p people] ^{:key (:nick p)} [member-row p]) |
| 1238 | 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 | 1260 | (defn chat-screen [] |
| 1241 | 1261 | (let [name @s/current |
| 1242 | 1262 | buffer (get @s/channels name) |
| @@ -1246,7 +1266,14 @@ | ||
| 1246 | 1266 | ;; that scrolls, bounded so what follows it keeps its room. |
| 1247 | 1267 | ;; Same margin all round: the compose row's own air is what centres it in |
| 1248 | 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 | 1277 | [:hbox {:spacing 8} |
| 1251 | 1278 | ;; The way back to the list, on a window with room for one thing at a |
| 1252 | 1279 | ;; time. Beside the list there is nothing to go back to, so the button |
| @@ -1295,11 +1322,15 @@ | ||
| 1295 | 1322 | [:vbox {:key :messages :fill-height true |
| 1296 | 1323 | :reserve (below-messages) |
| 1297 | 1324 | :width-request (if show-users? (messages-width) 0)} |
| 1298 | - [:scroll {:scroll-key "chat-messages" | |
| 1325 | + [:scroll {:scroll-key (messages-scroll-key) | |
| 1299 | 1326 | :orientation :vertical |
| 1300 | 1327 | :stick-to-bottom true |
| 1301 | 1328 | :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 | 1334 | (if (seq (:messages buffer)) |
| 1304 | 1335 | (message-rows (:messages buffer)) |
| 1305 | 1336 | [:dim-label {:label "Nothing here yet."}])]] |
| @@ -1315,7 +1346,13 @@ | ||
| 1315 | 1346 | [:vbox {:key :jump} |
| 1316 | 1347 | (if @s/at-present? |
| 1317 | 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 | 1356 | [:separator {}] |
| 1320 | 1357 | ;; What the draft is answering, directly above where it is being typed. |
| 1321 | 1358 | [: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/current | 1261 | (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 in | 1267 | ;; 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 a | 1278 | ;; 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 button | 1279 | ;; time. Beside the list there is nothing to go back to, so the button |
| @@ -1295,11 +1322,15 @@ | |||
| 1295 | [:vbox {:key :messages :fill-height true | 1322 | [: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 :vertical | 1326 | :orientation :vertical |
| 1300 | :stick-to-bottom true | 1327 | :stick-to-bottom true |
| 1301 | :scroll-to-bottom @s/jump-tick | 1328 | :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 @@ | ||
| 140 | 140 | (defonce at-present? (atom true)) |
| 141 | 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 | 174 | ;; A counter rather than a clock: the list only needs their order, and a |
| 145 | 175 | ;; monotonic tick cannot be surprised by the system time moving. |
| 146 | 176 | (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 a | 174 | ;; 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 @@ | ||
| 35 | 35 | [frq.state :as s] |
| 36 | 36 | [jolt.host :as host] |
| 37 | 37 | [glimmer.core :as ui] |
| 38 | + [glimmer-tui.ffi :as tui-ffi] | |
| 38 | 39 | ;; last, so its install! is the one that stands |
| 39 | 40 | [glimmer-tui.core :as tui])) |
| 40 | 41 | |
| @@ -107,6 +108,28 @@ | ||
| 107 | 108 | (known? (env "TERM_PROGRAM")) |
| 108 | 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 | 133 | (defn- narrow! |
| 111 | 134 | "Tell the layout how much room it has, in the units it expects. |
| 112 | 135 | |
| @@ -144,6 +167,9 @@ | ||
| 144 | 167 | ;; fetches the picture either way, and Kitty's protocol puts it over the |
| 145 | 168 | ;; cells the layout reserved for it. |
| 146 | 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 | 173 | (app/start! {:after! tui/after! |
| 148 | 174 | :every! tui/every! |
| 149 | 175 | :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 stands | 39 | ;; 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 the | 167 | ;; 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! nil | 175 | :title! nil |