nandi/frqpublic Fork 0
4f38d683abab6cac6a94ba3fe0ade290e8fd56a5
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 2705788 · on 4f38d683abab6cac6a94ba3fe0ade290e8fd56a5 · nandi · 15d ago
tui.jolt · 232 lines · 10.9 KBGDScript3 Blame HistoryRaw
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
(ns frq.tui
  "frq's own screens, painted into a terminal.

  The components in `frq.app` are hiccup over glimmer's reconciler, and the
  reconciler does not know what is under it  so the same tree that egui paints
  as a window goes through `libjolttui` as cells instead. Nothing in `frq.app`
  changes; this namespace only picks the other backend and gives it a screen.

  Requiring order is the whole trick. `frq.app` pulls in `glimmer-vidya.core`,
  which installs itself on load; `glimmer-tui.core` is required after it and
  installs itself in turn, so the backend glimmer renders with is the terminal.
  The Vidya library is still loaded and frq still calls into it  for a window
  title, a window width, a picture chooser  and with no window open every one
  of those is inert, which is the behaviour those calls already have to have.

  It is the client, not a preview of it: `frq.app/start!` is what a launch
  does, and this hands it the terminal's timers instead of the window's. The
  saved sign-in is restored and connects itself, the rooms this client has been
  in come back, and `#test` is joined on arrival — the same session as the
  window, painted somewhere else.

  Two ways to run it:

    jolt -M:tui                 the real terminal, until Ctrl-Q
    jolt -M:tui --headless      one screenshot on stdout, no terminal at all

  The headless one is `tui_headless`  the same layout and the same painting
  with the writer taken off the end  and is what a screenshot in a bug report
  or a CI check should be. It is also the one that has nothing to show: a
  screenshot taken a moment after launch is of a client that has not finished
  connecting, so `--demo` fills a buffer of its own rather than waiting on a
  server for one."
  (:require [clojure.string :as str]
            [frq.app :as app]
            [frq.state :as s]
            [jolt.host :as host]
            [glimmer.core :as ui]
            [glimmer-tui.ffi :as tui-ffi]
            ;; last, so its install! is the one that stands
            [glimmer-tui.core :as tui]))

(def ^:private demo-channel "#tui")

;; frq's spacing is written in points, because it was written for a window:
;; `:margin 12`, `:width-request 260`, a sidebar of 320. A cell is worth about
;; eight of those across, so that is the divisor the backend is handed  and
;; the same one `narrow!` multiplies back up, so `wide?` is asked its question
;; in the units it was written against.
(def ^:private points-per-cell 8)

;; And down the page a cell is worth about twice that: a character is roughly
;; twice as tall as it is wide, which is what decides how many messages fit.
(def ^:private points-per-row (* 2 points-per-cell))

(defn- seed!
  "Put a conversation on screen without a server behind it — `--demo`.

  A screenshot is taken before a connection has been made, and an empty buffer
  says nothing about how a buffer looks. These go in through `push-message!`
  rather than into the atom directly, so what is drawn is a real buffer:
  timestamps, day headings, the actor lookup, reply chips and reactions all
  included."
  []
  (let [now (System/currentTimeMillis)
        minute 60000]
    (s/push-message! demo-channel "*" (str "Now talking in " demo-channel)
                     {:at (- now (* 32 minute))})
    (s/push-message! demo-channel "nandi" "the tree ABI is the same one libvidya exports"
                     {:at (- now (* 12 minute)) :id "m1"})
    (s/push-message! demo-channel "vidya" "so the jolt side picks a window or a terminal and changes nothing else"
                     {:at (- now (* 11 minute)) :id "m2" :reply-to "m1"
                      :reactions {"👍" ["nandi"]}})
    (s/push-message! demo-channel "nandi" "one reconciler, two shared objects"
                     {:at (- now (* 2 minute)) :id "m3"}))
  (swap! s/channels assoc-in [demo-channel :joined?] true)
  (swap! s/channels assoc-in [demo-channel :users]
         {"nandi" "@" "vidya" "" "you" ""})
  (reset! s/form-nick "you")
  (reset! s/status (str "Not connected — " demo-channel " is a demo buffer"))
  (reset! s/current demo-channel)
  (reset! s/screen :chat)
  nil)

(defn- graphics?
  "Whether this terminal draws pictures over its cells.

  The same question `jolt-tui` asks itself before it reserves cells for an
  `:image`, and asked the same way: the protocol's own query comes back as
  input, long after the layout has had to decide how many rows a picture takes,
  so a terminal is identified by name here as it is everywhere else  and
  `JOLT_TUI_GRAPHICS` is the way in for one neither of us has learned yet.

  frq asks it too, rather than reading the answer back out of the library,
  because what hangs on it is a tree: a face on a message is an `:image` node
  or it is no node at all, and that is decided before the backend sees it."
  []
  (let [env (fn [k] (or (host/getenv k) ""))
        known? (fn [name]
                 (let [name (str/lower-case name)]
                   (boolean (some #(str/includes? name %)
                                  ["kitty" "ghostty" "wezterm"]))))]
    (case (str/lower-case (env "JOLT_TUI_GRAPHICS"))
      ("1" "true") true
      ("0" "false") false
      (or (seq (env "KITTY_WINDOW_ID"))
          (seq (env "GHOSTTY_RESOURCES_DIR"))
          (known? (env "TERM"))
          (known? (env "TERM_PROGRAM"))
          false))))

(defn- at-end?
  "Whether the scroll that is being reported ran into the end of its content.

  A terminal's viewport is moved by an offset and reports the offset it was
  asked for, so `to` on its own says nothing about where the bottom is. What
  says it is the clamp: painting fits the offset to the content it had and
  writes the fitted one back, so a request that came out smaller than it went
  in is a request that asked for more list than there was  which is a reader
  who has scrolled back down to the newest line.

  Read here, in the handler, because this is the one moment it is true of this
  scroll: the loop paints the frame the input caused and only then hands out
  the events, so the offset on the node is already this request, clamped. The
  render that follows will clear it.

  The one it cannot see is a page that lands exactly on the end  nothing was
  clamped, so it reads as a scroll like any other, and the button stays up for
  one more press. There is no max on the node to compare against, and asking
  for one is the jump itself."
  [to]
  (< (tui-ffi/node-get-num (tui-ffi/event-node) "offset") to))

(defn- narrow!
  "Tell the layout how much room it has, in the units it expects.

  `frq.state`'s `wide?` is written against a window's width in points, and the
  terminal's is in cells — two orders of magnitude smaller, so left alone every
  session would take the narrow layout by accident rather than on purpose. A
  cell is about eight points wide and a row about sixteen tall, which is close
  enough for the one question anything here asks of these numbers."
  []
  (let [[cols rows] (tui/screen-size)]
    (reset! s/window-width (* cols points-per-cell))
    ;; In the same points: the heights in frq  a picture's, the room kept for
    ;; the compose bar  are measured against the window's.
    (reset! s/window-height (* rows points-per-row)))
  nil)

(defn- start!
  "The launch, with the terminal's timers in place of the window's.

  No title and no media plane: a terminal has no title bar to write a nick
  into, and a call paints frames into a texture there is no texture for here.
  Everything else  the saved settings, the rooms, the sign-in that connects
  itself  is the same startup the window runs."
  []
  ;; A row of chrome here is one cell, not a window's 34 points, so the strip
  ;; under a scrolling list reserves the three rows it needs rather than the
  ;; nine the window's count works out to.
  (reset! app/chrome-row points-per-row)
  ;; And a message here is a name with its words hanging under it, rather than
  ;; a face with them beside it: the initial that stands in for a portrait in a
  ;; window is a letter printed twice here, which only pushed every nick in
  ;; past its own text.
  (reset! app/terminal? true)
  ;; The face itself comes back where the terminal can draw one: this client
  ;; fetches the picture either way, and Kitty's protocol puts it over the
  ;; cells the layout reserved for it.
  (reset! app/terminal-graphics? (boolean (graphics?)))
  ;; And how a scroll here answers "am I at the newest line?", which the
  ;; window's scroll area answers for itself.
  (reset! s/at-end-probe at-end?)
  (app/start! {:after! tui/after!
               :every! tui/every!
               :title! nil
               :av? false
               :measure! narrow!}))

(defn- run-headless!
  "Mount the app in a session of `cols` by `rows` with no terminal, let it
  settle, and print what was painted."
  [cols rows demo? wait dump?]
  ;; The size has to be known before the first render, or the layout reads the
  ;; zero it was left at. A timer runs on the loop thread, which is where
  ;; screen-size may be asked  and the first one fires before the first paint.
  (tui/after! 0 (fn []
                  (narrow!)
                  (start!)
                  (when demo? (seed!))))
  ;; Long enough for whatever the screenshot is of. A client that restores a
  ;; sign-in spends the first seconds connecting, so the default is a picture
  ;; of the connect screen and `--wait=` is how you ask for one of the room.
  (tui/after! wait (fn []
                    (println (tui/screen-str))
                    ;; The tree as the library holds it, which is what a report
                    ;; about a layout is actually about: props after scaling,
                    ;; and the shape the reconciler left behind.
                    (when dump? (println (tui/dump-str)))
                    (tui/quit!)))
  (ui/run app/app :headless [cols rows]
          :points-per-cell points-per-cell
          :points-per-row points-per-row))

(defn- run-terminal! [demo?]
  ;; On the loop thread, and before the first paint: `start!` sets timers of
  ;; its own, and a timer added from anywhere else is a node touched from the
  ;; wrong thread waiting to happen.
  (tui/after! 0 (fn []
                  (narrow!)
                  (start!)
                  (when demo? (seed!))))
  (ui/run app/app :mouse true
          :points-per-cell points-per-cell
          :points-per-row points-per-row))

(defn -main [& args]
  (let [args (set args)
        headless? (contains? args "--headless")
        demo? (contains? args "--demo")
        dump? (contains? args "--dump")
        num (fn [flag default]
              (if-let [n (first (keep #(when (str/starts-with? % flag)
                                         (subs % (count flag)))
                                      args))]
                (Integer/parseInt n)
                default))
        cols (num "--cols=" 100)
        rows (num "--rows=" 36)
        wait (num "--wait=" 400)]
    (if headless?
      (run-headless! cols rows demo? wait dump?)
      (run-terminal! demo?))))