Paint the same screens into a terminal
The screens are hiccup over glimmer's reconciler, and the reconciler does not know what is under it — so the tree that egui paints as a window goes through libjolttui as cells instead, and frq.app is untouched. frq.tui is the whole of what that costs here: it requires glimmer-tui.core after frq.app, so the backend installed last is the terminal, and seeds a #tui buffer through the real push-message! so a session started for a look at the layout has a conversation in it rather than an empty column. Two things are unpinned, because the backend is not in a jolt-native release: scripts/tui.bb takes libjolttui.so out of a checkout's target directory and resolves glimmer-tui from the same place, and the flake carries a second jolt-native input at the rev that has it — its own input rather than a bump, so the window half stays on the release the rest of the tree names. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ab83b42 parent: 4a372ae modified
README.md +33 -0 | @@ -60,6 +60,39 @@ plain listener: | ||
| 60 | 60 | cargo run --release --bin freeq-server # in the freeq checkout |
| 61 | 61 | ``` |
| 62 | 62 | |
| 63 | +## In a terminal | |
| 64 | + | |
| 65 | +The screens are hiccup over glimmer's reconciler, and the reconciler does not | |
| 66 | +know what is under it — so the same tree paints into a terminal through | |
| 67 | +jolt-native's `libjolttui`, which exports libvidya's retained-tree ABI over a | |
| 68 | +grid of cells instead of a GPU window. `src/frq/tui.jolt` is the whole of what | |
| 69 | +that costs on this side: it requires `glimmer-tui.core` after `frq.app`, so the | |
| 70 | +backend installed last is the terminal, and seeds a `#tui` buffer so a session | |
| 71 | +started for a look at the layout has a conversation in it. | |
| 72 | + | |
| 73 | +```bash | |
| 74 | +just tui # the terminal, until Ctrl-Q | |
| 75 | +just tui --headless --cols=90 --rows=60 # one screenshot on stdout | |
| 76 | +``` | |
| 77 | + | |
| 78 | +The headless one is `tui_headless` — the same layout and the same painting with | |
| 79 | +the writer taken off the end — which is what a screenshot in a bug report or a | |
| 80 | +CI check should be. | |
| 81 | + | |
| 82 | +Two things are unpinned here, because the terminal backend is not in a | |
| 83 | +jolt-native release yet: `libjolttui.so` comes out of a jolt-native checkout's | |
| 84 | +target directory (`JOLT_NATIVE=…`, or beside this tree), and `glimmer-tui` is | |
| 85 | +resolved from the same checkout. Both become pins like every other when it | |
| 86 | +ships. | |
| 87 | + | |
| 88 | +What a terminal has not got, frq does without: pictures, avatars, the lightbox | |
| 89 | +and calls draw nothing. And frq's spacing is written in points, for a window — | |
| 90 | +`scripts/tui.bb` hands the backend `:points-per-cell 8` so those numbers land | |
| 91 | +in cells, but `below-messages` in `src/frq/app.jolt` is point *arithmetic* | |
| 92 | +rather than a point *length*, and a scale cannot fix it: it reserves about | |
| 93 | +thirteen rows more than the compose bar needs, so the bottom of the backlog is | |
| 94 | +pushed out of the list. | |
| 95 | + | |
| 63 | 96 | ## Signing in |
| 64 | 97 | |
| 65 | 98 | Three modes on the connect screen. |
| @@ -60,6 +60,39 @@ plain listener: | |||
| 60 | cargo run --release --bin freeq-server # in the freeq checkout | 60 | cargo run --release --bin freeq-server # in the freeq checkout |
| 61 | ``` | 61 | ``` |
| 62 | 62 | ||
| 63 | +## In a terminal | ||
| 64 | + | ||
| 65 | +The screens are hiccup over glimmer's reconciler, and the reconciler does not | ||
| 66 | +know what is under it — so the same tree paints into a terminal through | ||
| 67 | +jolt-native's `libjolttui`, which exports libvidya's retained-tree ABI over a | ||
| 68 | +grid of cells instead of a GPU window. `src/frq/tui.jolt` is the whole of what | ||
| 69 | +that costs on this side: it requires `glimmer-tui.core` after `frq.app`, so the | ||
| 70 | +backend installed last is the terminal, and seeds a `#tui` buffer so a session | ||
| 71 | +started for a look at the layout has a conversation in it. | ||
| 72 | + | ||
| 73 | +```bash | ||
| 74 | +just tui # the terminal, until Ctrl-Q | ||
| 75 | +just tui --headless --cols=90 --rows=60 # one screenshot on stdout | ||
| 76 | +``` | ||
| 77 | + | ||
| 78 | +The headless one is `tui_headless` — the same layout and the same painting with | ||
| 79 | +the writer taken off the end — which is what a screenshot in a bug report or a | ||
| 80 | +CI check should be. | ||
| 81 | + | ||
| 82 | +Two things are unpinned here, because the terminal backend is not in a | ||
| 83 | +jolt-native release yet: `libjolttui.so` comes out of a jolt-native checkout's | ||
| 84 | +target directory (`JOLT_NATIVE=…`, or beside this tree), and `glimmer-tui` is | ||
| 85 | +resolved from the same checkout. Both become pins like every other when it | ||
| 86 | +ships. | ||
| 87 | + | ||
| 88 | +What a terminal has not got, frq does without: pictures, avatars, the lightbox | ||
| 89 | +and calls draw nothing. And frq's spacing is written in points, for a window — | ||
| 90 | +`scripts/tui.bb` hands the backend `:points-per-cell 8` so those numbers land | ||
| 91 | +in cells, but `below-messages` in `src/frq/app.jolt` is point *arithmetic* | ||
| 92 | +rather than a point *length*, and a scale cannot fix it: it reserves about | ||
| 93 | +thirteen rows more than the compose bar needs, so the bottom of the backlog is | ||
| 94 | +pushed out of the list. | ||
| 95 | + | ||
| 63 | ## Signing in | 96 | ## Signing in |
| 64 | 97 | ||
| 65 | Three modes on the connect screen. | 98 | Three modes on the connect screen. |
modified
deps.edn +17 -2 | @@ -34,6 +34,21 @@ | ||
| 34 | 34 | :darwin ["libjoltmoq.dylib"] |
| 35 | 35 | :linux ["libjoltmoq.so"]}] |
| 36 | 36 | |
| 37 | - :aliases {:frq {:main-opts ["-m" "frq.app"]}} | |
| 37 | + :aliases {:frq {:main-opts ["-m" "frq.app"]} | |
| 38 | 38 | |
| 39 | - :tasks {frq "jolt -M:frq"}} | |
| 39 | + ;; The same screens in a terminal. glimmer-tui is the other backend | |
| 40 | + ;; for the same reconciler — libjolttui's tree ABI is libvidya's | |
| 41 | + ;; with cells under it — so `frq.tui` requires it after `frq.app` | |
| 42 | + ;; and renders the hiccup that is already written. | |
| 43 | + ;; | |
| 44 | + ;; A path rather than a sha because the backend is not in a | |
| 45 | + ;; jolt-native release yet; it wants the checkout beside this one, | |
| 46 | + ;; and `libjolttui.so` on the library path: | |
| 47 | + ;; | |
| 48 | + ;; just tui | |
| 49 | + :tui {:extra-deps {nandi/glimmer-tui | |
| 50 | + {:local/root "../jolt-native/jolt/glimmer-tui"}} | |
| 51 | + :main-opts ["-m" "frq.tui"]}} | |
| 52 | + | |
| 53 | + :tasks {frq "jolt -M:frq" | |
| 54 | + tui "jolt -M:tui"}} | |
| @@ -34,6 +34,21 @@ | |||
| 34 | :darwin ["libjoltmoq.dylib"] | 34 | :darwin ["libjoltmoq.dylib"] |
| 35 | :linux ["libjoltmoq.so"]}] | 35 | :linux ["libjoltmoq.so"]}] |
| 36 | 36 | ||
| 37 | - :aliases {:frq {:main-opts ["-m" "frq.app"]}} | 37 | + :aliases {:frq {:main-opts ["-m" "frq.app"]} |
| 38 | 38 | ||
| 39 | - :tasks {frq "jolt -M:frq"}} | 39 | + ;; The same screens in a terminal. glimmer-tui is the other backend |
| 40 | + ;; for the same reconciler — libjolttui's tree ABI is libvidya's | ||
| 41 | + ;; with cells under it — so `frq.tui` requires it after `frq.app` | ||
| 42 | + ;; and renders the hiccup that is already written. | ||
| 43 | + ;; | ||
| 44 | + ;; A path rather than a sha because the backend is not in a | ||
| 45 | + ;; jolt-native release yet; it wants the checkout beside this one, | ||
| 46 | + ;; and `libjolttui.so` on the library path: | ||
| 47 | + ;; | ||
| 48 | + ;; just tui | ||
| 49 | + :tui {:extra-deps {nandi/glimmer-tui | ||
| 50 | + {:local/root "../jolt-native/jolt/glimmer-tui"}} | ||
| 51 | + :main-opts ["-m" "frq.tui"]}} | ||
| 52 | + | ||
| 53 | + :tasks {frq "jolt -M:frq" | ||
| 54 | + tui "jolt -M:tui"}} | ||
modified
flake.lock +18 -0 | @@ -124,6 +124,23 @@ | ||
| 124 | 124 | "url": "https://gitlab.com/nandithebull/jolt-native" |
| 125 | 125 | } |
| 126 | 126 | }, |
| 127 | + "jolt-native-tui": { | |
| 128 | + "flake": false, | |
| 129 | + "locked": { | |
| 130 | + "lastModified": 1788328565, | |
| 131 | + "narHash": "sha256-wqfStc5zgi+kslskv/JnPAGZCaxGvZ8e/V2+6qpLF8I=", | |
| 132 | + "ref": "refs/heads/main", | |
| 133 | + "rev": "3cfee15a9d938584f526f606f853771eaad7f56c", | |
| 134 | + "revCount": 32, | |
| 135 | + "type": "git", | |
| 136 | + "url": "https://gitlab.com/nandithebull/jolt-native" | |
| 137 | + }, | |
| 138 | + "original": { | |
| 139 | + "rev": "3cfee15a9d938584f526f606f853771eaad7f56c", | |
| 140 | + "type": "git", | |
| 141 | + "url": "https://gitlab.com/nandithebull/jolt-native" | |
| 142 | + } | |
| 143 | + }, | |
| 127 | 144 | "jolt-src": { |
| 128 | 145 | "flake": false, |
| 129 | 146 | "locked": { |
| @@ -207,6 +224,7 @@ | ||
| 207 | 224 | "glimmer": "glimmer", |
| 208 | 225 | "jolt-android-src": "jolt-android-src", |
| 209 | 226 | "jolt-native": "jolt-native", |
| 227 | + "jolt-native-tui": "jolt-native-tui", | |
| 210 | 228 | "jolt-src": "jolt-src", |
| 211 | 229 | "nix-appimage": "nix-appimage", |
| 212 | 230 | "nixgl": "nixgl", |
| @@ -124,6 +124,23 @@ | |||
| 124 | "url": "https://gitlab.com/nandithebull/jolt-native" | 124 | "url": "https://gitlab.com/nandithebull/jolt-native" |
| 125 | } | 125 | } |
| 126 | }, | 126 | }, |
| 127 | + "jolt-native-tui": { | ||
| 128 | + "flake": false, | ||
| 129 | + "locked": { | ||
| 130 | + "lastModified": 1788328565, | ||
| 131 | + "narHash": "sha256-wqfStc5zgi+kslskv/JnPAGZCaxGvZ8e/V2+6qpLF8I=", | ||
| 132 | + "ref": "refs/heads/main", | ||
| 133 | + "rev": "3cfee15a9d938584f526f606f853771eaad7f56c", | ||
| 134 | + "revCount": 32, | ||
| 135 | + "type": "git", | ||
| 136 | + "url": "https://gitlab.com/nandithebull/jolt-native" | ||
| 137 | + }, | ||
| 138 | + "original": { | ||
| 139 | + "rev": "3cfee15a9d938584f526f606f853771eaad7f56c", | ||
| 140 | + "type": "git", | ||
| 141 | + "url": "https://gitlab.com/nandithebull/jolt-native" | ||
| 142 | + } | ||
| 143 | + }, | ||
| 127 | "jolt-src": { | 144 | "jolt-src": { |
| 128 | "flake": false, | 145 | "flake": false, |
| 129 | "locked": { | 146 | "locked": { |
| @@ -207,6 +224,7 @@ | |||
| 207 | "glimmer": "glimmer", | 224 | "glimmer": "glimmer", |
| 208 | "jolt-android-src": "jolt-android-src", | 225 | "jolt-android-src": "jolt-android-src", |
| 209 | "jolt-native": "jolt-native", | 226 | "jolt-native": "jolt-native", |
| 227 | + "jolt-native-tui": "jolt-native-tui", | ||
| 210 | "jolt-src": "jolt-src", | 228 | "jolt-src": "jolt-src", |
| 211 | "nix-appimage": "nix-appimage", | 229 | "nix-appimage": "nix-appimage", |
| 212 | "nixgl": "nixgl", | 230 | "nixgl": "nixgl", |
modified
flake.nix +81 -1 | @@ -41,6 +41,17 @@ | ||
| 41 | 41 | flake = false; |
| 42 | 42 | }; |
| 43 | 43 | |
| 44 | + # The terminal backend, which v0.1.3 has not got: crates/jolt-tui (the tree | |
| 45 | + # ABI over a grid of cells) and jolt/glimmer-tui (the jolt side that binds | |
| 46 | + # it). Its own input rather than a bump of the one above, deliberately — | |
| 47 | + # the window half stays pinned to the release the rest of the tree names, | |
| 48 | + # and only `tui` evaluates this. When the backend ships in a release the | |
| 49 | + # two become one pin again. | |
| 50 | + jolt-native-tui = { | |
| 51 | + url = "git+https://gitlab.com/nandithebull/jolt-native?rev=3cfee15a9d938584f526f606f853771eaad7f56c"; | |
| 52 | + flake = false; | |
| 53 | + }; | |
| 54 | + | |
| 44 | 55 | # Chez itself, because the APK needs a cross target nixpkgs does not |
| 45 | 56 | # build: frq's Scheme is compiled to an arm64 boot image, and that wants |
| 46 | 57 | # Chez's own `tarm64le` workarea — boot files, xpatch and libkernel.a. |
| @@ -81,7 +92,7 @@ | ||
| 81 | 92 | }; |
| 82 | 93 | }; |
| 83 | 94 | |
| 84 | - outputs = { self, nixpkgs, jolt-src, jolt-native, glimmer, chez-src, jolt-android-src, nixgl, nix-appimage }: | |
| 95 | + outputs = { self, nixpkgs, jolt-src, jolt-native, jolt-native-tui, glimmer, chez-src, jolt-android-src, nixgl, nix-appimage }: | |
| 85 | 96 | let |
| 86 | 97 | systems = [ "x86_64-linux" "aarch64-linux" ]; |
| 87 | 98 | forEachSystem = f: |
| @@ -148,6 +159,44 @@ | ||
| 148 | 159 | ''; |
| 149 | 160 | }; |
| 150 | 161 | |
| 162 | + # libjolttui alone, out of the tui input's workspace. One crate | |
| 163 | + # rather than the whole of it: the terminal backend's dependencies | |
| 164 | + # are crossterm and a width table, where libvidya's and libjoltmoq's | |
| 165 | + # are egui, openh264 and v4l — none of which a terminal needs, and | |
| 166 | + # all of which this would otherwise build a second time at a second | |
| 167 | + # rev. | |
| 168 | + nativeTui = pkgs.rustPlatform.buildRustPackage { | |
| 169 | + pname = "jolt-tui"; | |
| 170 | + version = "0.1.0"; | |
| 171 | + src = jolt-native-tui; | |
| 172 | + | |
| 173 | + cargoLock = { | |
| 174 | + lockFile = "${jolt-native-tui}/Cargo.lock"; | |
| 175 | + allowBuiltinFetchGit = true; | |
| 176 | + }; | |
| 177 | + | |
| 178 | + nativeBuildInputs = with pkgs; [ pkg-config cmake rustPlatform.bindgenHook ]; | |
| 179 | + buildInputs = with pkgs; [ alsa-lib pipewire openssl libxkbcommon wayland libGL ]; | |
| 180 | + | |
| 181 | + cargoBuildFlags = [ "-p" "jolt-tui" ]; | |
| 182 | + | |
| 183 | + # As above: upstream's .cargo/config.toml drives the build through | |
| 184 | + # DotSlash, which a sandbox has no network for. | |
| 185 | + postPatch = '' | |
| 186 | + rm -f .cargo/config.toml | |
| 187 | + ''; | |
| 188 | + | |
| 189 | + V4L2R_VIDEODEV2_H_PATH = "${pkgs.linuxHeaders}/include"; | |
| 190 | + doCheck = false; | |
| 191 | + | |
| 192 | + installPhase = '' | |
| 193 | + runHook preInstall | |
| 194 | + mkdir -p "$out/lib" | |
| 195 | + install -m644 target/*/release/libjolttui.so "$out/lib/" | |
| 196 | + runHook postInstall | |
| 197 | + ''; | |
| 198 | + }; | |
| 199 | + | |
| 151 | 200 | # Jolt itself: Clojure on Chez, built the way its own flake builds it. |
| 152 | 201 | # A function, because there are two of them — upstream for the |
| 153 | 202 | # desktop, and the Bionic-addrinfo fork for the boot image the APK |
| @@ -201,6 +250,7 @@ | ||
| 201 | 250 | # deps.edn asks for glimmer by git — the top-level override below |
| 202 | 251 | # answers for both. |
| 203 | 252 | glimmerVidya = "${jolt-native}/jolt/glimmer-vidya"; |
| 253 | + glimmerTui = "${jolt-native-tui}/jolt/glimmer-tui"; | |
| 204 | 254 | |
| 205 | 255 | # egui reaches for these with dlopen rather than linking them, so |
| 206 | 256 | # being in the cdylib's buildInputs is not enough — the launcher has |
| @@ -243,6 +293,31 @@ | ||
| 243 | 293 | -M:frq "$@" |
| 244 | 294 | ''; |
| 245 | 295 | |
| 296 | + # The same source, the other backend. No GL, no nixGL and no X11 — | |
| 297 | + # a terminal is the one surface that needs nothing from the host but | |
| 298 | + # a terminal, which is the reason this output exists. | |
| 299 | + tuiScript = pkgs.writeShellScript "frq-tui" '' | |
| 300 | + export LD_LIBRARY_PATH="${nativeTui}/lib:${native}/lib''${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" | |
| 301 | + cd ${frqSource} | |
| 302 | + | |
| 303 | + exec ${joltRuntime}/bin/jolt \ | |
| 304 | + -Sdeps '{:deps {jolt-lang/glimmer {:local/root "${glimmer}"} nandi/glimmer-vidya {:local/root "${glimmerVidya}"} nandi/glimmer-tui {:local/root "${glimmerTui}"}}}' \ | |
| 305 | + -m frq.tui "$@" | |
| 306 | + ''; | |
| 307 | + | |
| 308 | + tui = pkgs.runCommand "frq-tui-0.1.0" | |
| 309 | + { | |
| 310 | + meta = { | |
| 311 | + description = "frq's screens in a terminal"; | |
| 312 | + mainProgram = "frq-tui"; | |
| 313 | + platforms = systems; | |
| 314 | + }; | |
| 315 | + } | |
| 316 | + '' | |
| 317 | + mkdir -p "$out/bin" | |
| 318 | + ln -s ${tuiScript} "$out/bin/frq-tui" | |
| 319 | + ''; | |
| 320 | + | |
| 246 | 321 | frq = pkgs.runCommand "frq-0.1.0" |
| 247 | 322 | { |
| 248 | 323 | meta = { |
| @@ -286,6 +361,7 @@ | ||
| 286 | 361 | in |
| 287 | 362 | { |
| 288 | 363 | inherit native frq; |
| 364 | + inherit nativeTui tui; | |
| 289 | 365 | jolt = joltRuntime; |
| 290 | 366 | default = frq; |
| 291 | 367 | |
| @@ -309,6 +385,10 @@ | ||
| 309 | 385 | type = "app"; |
| 310 | 386 | program = "${self.packages.${pkgs.stdenv.hostPlatform.system}.frq}/bin/frq"; |
| 311 | 387 | }; |
| 388 | + tui = { | |
| 389 | + type = "app"; | |
| 390 | + program = "${self.packages.${pkgs.stdenv.hostPlatform.system}.tui}/bin/frq-tui"; | |
| 391 | + }; | |
| 312 | 392 | }); |
| 313 | 393 | }; |
| 314 | 394 | } |
| @@ -41,6 +41,17 @@ | |||
| 41 | flake = false; | 41 | flake = false; |
| 42 | }; | 42 | }; |
| 43 | 43 | ||
| 44 | + # The terminal backend, which v0.1.3 has not got: crates/jolt-tui (the tree | ||
| 45 | + # ABI over a grid of cells) and jolt/glimmer-tui (the jolt side that binds | ||
| 46 | + # it). Its own input rather than a bump of the one above, deliberately — | ||
| 47 | + # the window half stays pinned to the release the rest of the tree names, | ||
| 48 | + # and only `tui` evaluates this. When the backend ships in a release the | ||
| 49 | + # two become one pin again. | ||
| 50 | + jolt-native-tui = { | ||
| 51 | + url = "git+https://gitlab.com/nandithebull/jolt-native?rev=3cfee15a9d938584f526f606f853771eaad7f56c"; | ||
| 52 | + flake = false; | ||
| 53 | + }; | ||
| 54 | + | ||
| 44 | # Chez itself, because the APK needs a cross target nixpkgs does not | 55 | # Chez itself, because the APK needs a cross target nixpkgs does not |
| 45 | # build: frq's Scheme is compiled to an arm64 boot image, and that wants | 56 | # build: frq's Scheme is compiled to an arm64 boot image, and that wants |
| 46 | # Chez's own `tarm64le` workarea — boot files, xpatch and libkernel.a. | 57 | # Chez's own `tarm64le` workarea — boot files, xpatch and libkernel.a. |
| @@ -81,7 +92,7 @@ | |||
| 81 | }; | 92 | }; |
| 82 | }; | 93 | }; |
| 83 | 94 | ||
| 84 | - outputs = { self, nixpkgs, jolt-src, jolt-native, glimmer, chez-src, jolt-android-src, nixgl, nix-appimage }: | 95 | + outputs = { self, nixpkgs, jolt-src, jolt-native, jolt-native-tui, glimmer, chez-src, jolt-android-src, nixgl, nix-appimage }: |
| 85 | let | 96 | let |
| 86 | systems = [ "x86_64-linux" "aarch64-linux" ]; | 97 | systems = [ "x86_64-linux" "aarch64-linux" ]; |
| 87 | forEachSystem = f: | 98 | forEachSystem = f: |
| @@ -148,6 +159,44 @@ | |||
| 148 | ''; | 159 | ''; |
| 149 | }; | 160 | }; |
| 150 | 161 | ||
| 162 | + # libjolttui alone, out of the tui input's workspace. One crate | ||
| 163 | + # rather than the whole of it: the terminal backend's dependencies | ||
| 164 | + # are crossterm and a width table, where libvidya's and libjoltmoq's | ||
| 165 | + # are egui, openh264 and v4l — none of which a terminal needs, and | ||
| 166 | + # all of which this would otherwise build a second time at a second | ||
| 167 | + # rev. | ||
| 168 | + nativeTui = pkgs.rustPlatform.buildRustPackage { | ||
| 169 | + pname = "jolt-tui"; | ||
| 170 | + version = "0.1.0"; | ||
| 171 | + src = jolt-native-tui; | ||
| 172 | + | ||
| 173 | + cargoLock = { | ||
| 174 | + lockFile = "${jolt-native-tui}/Cargo.lock"; | ||
| 175 | + allowBuiltinFetchGit = true; | ||
| 176 | + }; | ||
| 177 | + | ||
| 178 | + nativeBuildInputs = with pkgs; [ pkg-config cmake rustPlatform.bindgenHook ]; | ||
| 179 | + buildInputs = with pkgs; [ alsa-lib pipewire openssl libxkbcommon wayland libGL ]; | ||
| 180 | + | ||
| 181 | + cargoBuildFlags = [ "-p" "jolt-tui" ]; | ||
| 182 | + | ||
| 183 | + # As above: upstream's .cargo/config.toml drives the build through | ||
| 184 | + # DotSlash, which a sandbox has no network for. | ||
| 185 | + postPatch = '' | ||
| 186 | + rm -f .cargo/config.toml | ||
| 187 | + ''; | ||
| 188 | + | ||
| 189 | + V4L2R_VIDEODEV2_H_PATH = "${pkgs.linuxHeaders}/include"; | ||
| 190 | + doCheck = false; | ||
| 191 | + | ||
| 192 | + installPhase = '' | ||
| 193 | + runHook preInstall | ||
| 194 | + mkdir -p "$out/lib" | ||
| 195 | + install -m644 target/*/release/libjolttui.so "$out/lib/" | ||
| 196 | + runHook postInstall | ||
| 197 | + ''; | ||
| 198 | + }; | ||
| 199 | + | ||
| 151 | # Jolt itself: Clojure on Chez, built the way its own flake builds it. | 200 | # Jolt itself: Clojure on Chez, built the way its own flake builds it. |
| 152 | # A function, because there are two of them — upstream for the | 201 | # A function, because there are two of them — upstream for the |
| 153 | # desktop, and the Bionic-addrinfo fork for the boot image the APK | 202 | # desktop, and the Bionic-addrinfo fork for the boot image the APK |
| @@ -201,6 +250,7 @@ | |||
| 201 | # deps.edn asks for glimmer by git — the top-level override below | 250 | # deps.edn asks for glimmer by git — the top-level override below |
| 202 | # answers for both. | 251 | # answers for both. |
| 203 | glimmerVidya = "${jolt-native}/jolt/glimmer-vidya"; | 252 | glimmerVidya = "${jolt-native}/jolt/glimmer-vidya"; |
| 253 | + glimmerTui = "${jolt-native-tui}/jolt/glimmer-tui"; | ||
| 204 | 254 | ||
| 205 | # egui reaches for these with dlopen rather than linking them, so | 255 | # egui reaches for these with dlopen rather than linking them, so |
| 206 | # being in the cdylib's buildInputs is not enough — the launcher has | 256 | # being in the cdylib's buildInputs is not enough — the launcher has |
| @@ -243,6 +293,31 @@ | |||
| 243 | -M:frq "$@" | 293 | -M:frq "$@" |
| 244 | ''; | 294 | ''; |
| 245 | 295 | ||
| 296 | + # The same source, the other backend. No GL, no nixGL and no X11 — | ||
| 297 | + # a terminal is the one surface that needs nothing from the host but | ||
| 298 | + # a terminal, which is the reason this output exists. | ||
| 299 | + tuiScript = pkgs.writeShellScript "frq-tui" '' | ||
| 300 | + export LD_LIBRARY_PATH="${nativeTui}/lib:${native}/lib''${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" | ||
| 301 | + cd ${frqSource} | ||
| 302 | + | ||
| 303 | + exec ${joltRuntime}/bin/jolt \ | ||
| 304 | + -Sdeps '{:deps {jolt-lang/glimmer {:local/root "${glimmer}"} nandi/glimmer-vidya {:local/root "${glimmerVidya}"} nandi/glimmer-tui {:local/root "${glimmerTui}"}}}' \ | ||
| 305 | + -m frq.tui "$@" | ||
| 306 | + ''; | ||
| 307 | + | ||
| 308 | + tui = pkgs.runCommand "frq-tui-0.1.0" | ||
| 309 | + { | ||
| 310 | + meta = { | ||
| 311 | + description = "frq's screens in a terminal"; | ||
| 312 | + mainProgram = "frq-tui"; | ||
| 313 | + platforms = systems; | ||
| 314 | + }; | ||
| 315 | + } | ||
| 316 | + '' | ||
| 317 | + mkdir -p "$out/bin" | ||
| 318 | + ln -s ${tuiScript} "$out/bin/frq-tui" | ||
| 319 | + ''; | ||
| 320 | + | ||
| 246 | frq = pkgs.runCommand "frq-0.1.0" | 321 | frq = pkgs.runCommand "frq-0.1.0" |
| 247 | { | 322 | { |
| 248 | meta = { | 323 | meta = { |
| @@ -286,6 +361,7 @@ | |||
| 286 | in | 361 | in |
| 287 | { | 362 | { |
| 288 | inherit native frq; | 363 | inherit native frq; |
| 364 | + inherit nativeTui tui; | ||
| 289 | jolt = joltRuntime; | 365 | jolt = joltRuntime; |
| 290 | default = frq; | 366 | default = frq; |
| 291 | 367 | ||
| @@ -309,6 +385,10 @@ | |||
| 309 | type = "app"; | 385 | type = "app"; |
| 310 | program = "${self.packages.${pkgs.stdenv.hostPlatform.system}.frq}/bin/frq"; | 386 | program = "${self.packages.${pkgs.stdenv.hostPlatform.system}.frq}/bin/frq"; |
| 311 | }; | 387 | }; |
| 388 | + tui = { | ||
| 389 | + type = "app"; | ||
| 390 | + program = "${self.packages.${pkgs.stdenv.hostPlatform.system}.tui}/bin/frq-tui"; | ||
| 391 | + }; | ||
| 312 | }); | 392 | }); |
| 313 | }; | 393 | }; |
| 314 | } | 394 | } |
modified
justfile +4 -0 | @@ -31,3 +31,7 @@ sync-dist: | ||
| 31 | 31 | # The app. |
| 32 | 32 | run *args: |
| 33 | 33 | scripts/run.bb {{args}} |
| 34 | + | |
| 35 | +# The same screens in a terminal. `just tui --headless` prints one screenshot. | |
| 36 | +tui *args: | |
| 37 | + scripts/tui.bb {{args}} | |
| @@ -31,3 +31,7 @@ sync-dist: | |||
| 31 | # The app. | 31 | # The app. |
| 32 | run *args: | 32 | run *args: |
| 33 | scripts/run.bb {{args}} | 33 | scripts/run.bb {{args}} |
| 34 | + | ||
| 35 | +# The same screens in a terminal. `just tui --headless` prints one screenshot. | ||
| 36 | +tui *args: | ||
| 37 | + scripts/tui.bb {{args}} | ||
added
scripts/tui.bb +95 -0 | new file mode 100755 | ||
| @@ -0,0 +1,95 @@ | ||
| 1 | +#!/bin/sh | |
| 2 | +#_( | |
| 3 | +exec "$(dirname "$0")/bb" "$0" "$@" | |
| 4 | +) | |
| 5 | + | |
| 6 | +;; frq's screens in a terminal, from this working tree. | |
| 7 | +;; | |
| 8 | +;; tui.bb [--headless] [--cols=N] [--rows=N] | |
| 9 | +;; | |
| 10 | +;; run.bb with the other backend under it. Two things differ, and both are | |
| 11 | +;; because the terminal backend is not in a jolt-native release yet: | |
| 12 | +;; | |
| 13 | +;; * `libjolttui.so` comes out of a jolt-native checkout's target directory | |
| 14 | +;; rather than out of a pinned archive, and is linked into build/lib beside | |
| 15 | +;; the two that are pinned. Point JOLT_NATIVE at that checkout, or leave it | |
| 16 | +;; beside this one. | |
| 17 | +;; * `glimmer-tui` — the jolt half of that backend, and the namespace | |
| 18 | +;; `frq.tui` requires — is resolved from the same checkout with -Sdeps, | |
| 19 | +;; which is what lets this work from a worktree, where the relative path in | |
| 20 | +;; deps.edn's `:tui` alias does not point where it does from a clone. The | |
| 21 | +;; alias is not used here for that reason: its coordinate for the same | |
| 22 | +;; library wins over the one -Sdeps merges in, so this passes `-m` itself | |
| 23 | +;; and leaves `jolt -M:tui` to the checkouts the alias is right for. | |
| 24 | +;; | |
| 25 | +;; When the backend ships in a release, both of those become pins like every | |
| 26 | +;; other, and this script becomes run.bb with a different alias. | |
| 27 | +(require '[babashka.fs :as fs] | |
| 28 | + '[babashka.process :as p]) | |
| 29 | + | |
| 30 | +(def root (str (fs/canonicalize (fs/path (fs/parent *file*) "..")))) | |
| 31 | +(def lib (fs/path root "build" "lib")) | |
| 32 | + | |
| 33 | +(def jolt | |
| 34 | + (let [pin (fs/path root "scripts" "jolt")] | |
| 35 | + (if (and (fs/exists? pin) (fs/which "dotslash")) (str pin) "jolt"))) | |
| 36 | + | |
| 37 | +(defn die [& msg] | |
| 38 | + (binding [*out* *err*] (println (apply str msg))) | |
| 39 | + (System/exit 1)) | |
| 40 | + | |
| 41 | +;; The checkout the terminal backend lives in. A worktree of it counts — what | |
| 42 | +;; is wanted is a directory holding `jolt/glimmer-tui` and a built libjolttui. | |
| 43 | +(def jolt-native | |
| 44 | + (let [given (System/getenv "JOLT_NATIVE") | |
| 45 | + guess (fs/path root ".." "jolt-native") | |
| 46 | + dir (cond | |
| 47 | + given (fs/path given) | |
| 48 | + (fs/exists? guess) guess | |
| 49 | + :else nil)] | |
| 50 | + (when-not dir | |
| 51 | + (die "no jolt-native checkout: set JOLT_NATIVE, or put one beside this tree")) | |
| 52 | + (str (fs/canonicalize dir)))) | |
| 53 | + | |
| 54 | +(def glimmer-tui (fs/path jolt-native "jolt" "glimmer-tui")) | |
| 55 | + | |
| 56 | +(when-not (fs/exists? glimmer-tui) | |
| 57 | + (die glimmer-tui " is not there — this wants the branch that carries the " | |
| 58 | + "terminal backend (crates/jolt-tui and jolt/glimmer-tui)")) | |
| 59 | + | |
| 60 | +;; cargo's release build first, then its debug one, then what buck2 left: any | |
| 61 | +;; of the three is the library, and which one a person has is their business. | |
| 62 | +(def jolttui | |
| 63 | + (or (first (for [c ["target/release/libjolttui.so" | |
| 64 | + "target/debug/libjolttui.so"] | |
| 65 | + :let [p (fs/path jolt-native c)] | |
| 66 | + :when (fs/exists? p)] | |
| 67 | + p)) | |
| 68 | + (first (sort-by (comp - fs/file-time->millis fs/last-modified-time) | |
| 69 | + (fs/glob (fs/path jolt-native "buck-out") | |
| 70 | + "**/libjolttui.so"))) | |
| 71 | + (die "no libjolttui.so under " jolt-native | |
| 72 | + " — build it: cargo build --release -p jolt-tui"))) | |
| 73 | + | |
| 74 | +;; The two pinned libraries, into build/lib. | |
| 75 | +(p/shell (str (fs/path root "scripts" "lib.bb"))) | |
| 76 | + | |
| 77 | +;; And the unpinned third, linked rather than copied for the same reason the | |
| 78 | +;; others are: a rebuild reaches a running tree without anything here going | |
| 79 | +;; stale. | |
| 80 | +(let [dest (fs/path lib "libjolttui.so")] | |
| 81 | + (fs/create-dirs lib) | |
| 82 | + (fs/delete-if-exists dest) | |
| 83 | + (fs/create-sym-link dest (str (fs/canonicalize jolttui)))) | |
| 84 | + | |
| 85 | +(System/exit | |
| 86 | + (:exit @(apply p/process | |
| 87 | + {:inherit true | |
| 88 | + :dir root | |
| 89 | + :extra-env {"LD_LIBRARY_PATH" | |
| 90 | + (str lib (when-let [p (System/getenv "LD_LIBRARY_PATH")] | |
| 91 | + (str ":" p)))}} | |
| 92 | + jolt | |
| 93 | + "-Sdeps" (str "{:deps {nandi/glimmer-tui {:local/root \"" | |
| 94 | + glimmer-tui "\"}}}") | |
| 95 | + "-m" "frq.tui" *command-line-args*))) | |
| new file mode 100755 | |||
| @@ -0,0 +1,95 @@ | |||
| 1 | +#!/bin/sh | ||
| 2 | +#_( | ||
| 3 | +exec "$(dirname "$0")/bb" "$0" "$@" | ||
| 4 | +) | ||
| 5 | + | ||
| 6 | +;; frq's screens in a terminal, from this working tree. | ||
| 7 | +;; | ||
| 8 | +;; tui.bb [--headless] [--cols=N] [--rows=N] | ||
| 9 | +;; | ||
| 10 | +;; run.bb with the other backend under it. Two things differ, and both are | ||
| 11 | +;; because the terminal backend is not in a jolt-native release yet: | ||
| 12 | +;; | ||
| 13 | +;; * `libjolttui.so` comes out of a jolt-native checkout's target directory | ||
| 14 | +;; rather than out of a pinned archive, and is linked into build/lib beside | ||
| 15 | +;; the two that are pinned. Point JOLT_NATIVE at that checkout, or leave it | ||
| 16 | +;; beside this one. | ||
| 17 | +;; * `glimmer-tui` — the jolt half of that backend, and the namespace | ||
| 18 | +;; `frq.tui` requires — is resolved from the same checkout with -Sdeps, | ||
| 19 | +;; which is what lets this work from a worktree, where the relative path in | ||
| 20 | +;; deps.edn's `:tui` alias does not point where it does from a clone. The | ||
| 21 | +;; alias is not used here for that reason: its coordinate for the same | ||
| 22 | +;; library wins over the one -Sdeps merges in, so this passes `-m` itself | ||
| 23 | +;; and leaves `jolt -M:tui` to the checkouts the alias is right for. | ||
| 24 | +;; | ||
| 25 | +;; When the backend ships in a release, both of those become pins like every | ||
| 26 | +;; other, and this script becomes run.bb with a different alias. | ||
| 27 | +(require '[babashka.fs :as fs] | ||
| 28 | + '[babashka.process :as p]) | ||
| 29 | + | ||
| 30 | +(def root (str (fs/canonicalize (fs/path (fs/parent *file*) "..")))) | ||
| 31 | +(def lib (fs/path root "build" "lib")) | ||
| 32 | + | ||
| 33 | +(def jolt | ||
| 34 | + (let [pin (fs/path root "scripts" "jolt")] | ||
| 35 | + (if (and (fs/exists? pin) (fs/which "dotslash")) (str pin) "jolt"))) | ||
| 36 | + | ||
| 37 | +(defn die [& msg] | ||
| 38 | + (binding [*out* *err*] (println (apply str msg))) | ||
| 39 | + (System/exit 1)) | ||
| 40 | + | ||
| 41 | +;; The checkout the terminal backend lives in. A worktree of it counts — what | ||
| 42 | +;; is wanted is a directory holding `jolt/glimmer-tui` and a built libjolttui. | ||
| 43 | +(def jolt-native | ||
| 44 | + (let [given (System/getenv "JOLT_NATIVE") | ||
| 45 | + guess (fs/path root ".." "jolt-native") | ||
| 46 | + dir (cond | ||
| 47 | + given (fs/path given) | ||
| 48 | + (fs/exists? guess) guess | ||
| 49 | + :else nil)] | ||
| 50 | + (when-not dir | ||
| 51 | + (die "no jolt-native checkout: set JOLT_NATIVE, or put one beside this tree")) | ||
| 52 | + (str (fs/canonicalize dir)))) | ||
| 53 | + | ||
| 54 | +(def glimmer-tui (fs/path jolt-native "jolt" "glimmer-tui")) | ||
| 55 | + | ||
| 56 | +(when-not (fs/exists? glimmer-tui) | ||
| 57 | + (die glimmer-tui " is not there — this wants the branch that carries the " | ||
| 58 | + "terminal backend (crates/jolt-tui and jolt/glimmer-tui)")) | ||
| 59 | + | ||
| 60 | +;; cargo's release build first, then its debug one, then what buck2 left: any | ||
| 61 | +;; of the three is the library, and which one a person has is their business. | ||
| 62 | +(def jolttui | ||
| 63 | + (or (first (for [c ["target/release/libjolttui.so" | ||
| 64 | + "target/debug/libjolttui.so"] | ||
| 65 | + :let [p (fs/path jolt-native c)] | ||
| 66 | + :when (fs/exists? p)] | ||
| 67 | + p)) | ||
| 68 | + (first (sort-by (comp - fs/file-time->millis fs/last-modified-time) | ||
| 69 | + (fs/glob (fs/path jolt-native "buck-out") | ||
| 70 | + "**/libjolttui.so"))) | ||
| 71 | + (die "no libjolttui.so under " jolt-native | ||
| 72 | + " — build it: cargo build --release -p jolt-tui"))) | ||
| 73 | + | ||
| 74 | +;; The two pinned libraries, into build/lib. | ||
| 75 | +(p/shell (str (fs/path root "scripts" "lib.bb"))) | ||
| 76 | + | ||
| 77 | +;; And the unpinned third, linked rather than copied for the same reason the | ||
| 78 | +;; others are: a rebuild reaches a running tree without anything here going | ||
| 79 | +;; stale. | ||
| 80 | +(let [dest (fs/path lib "libjolttui.so")] | ||
| 81 | + (fs/create-dirs lib) | ||
| 82 | + (fs/delete-if-exists dest) | ||
| 83 | + (fs/create-sym-link dest (str (fs/canonicalize jolttui)))) | ||
| 84 | + | ||
| 85 | +(System/exit | ||
| 86 | + (:exit @(apply p/process | ||
| 87 | + {:inherit true | ||
| 88 | + :dir root | ||
| 89 | + :extra-env {"LD_LIBRARY_PATH" | ||
| 90 | + (str lib (when-let [p (System/getenv "LD_LIBRARY_PATH")] | ||
| 91 | + (str ":" p)))}} | ||
| 92 | + jolt | ||
| 93 | + "-Sdeps" (str "{:deps {nandi/glimmer-tui {:local/root \"" | ||
| 94 | + glimmer-tui "\"}}}") | ||
| 95 | + "-m" "frq.tui" *command-line-args*))) | ||
added
src/frq/tui.jolt +123 -0 | new file mode 100644 | ||
| @@ -0,0 +1,123 @@ | ||
| 1 | +(ns frq.tui | |
| 2 | + "frq's own screens, painted into a terminal. | |
| 3 | + | |
| 4 | + The components in `frq.app` are hiccup over glimmer's reconciler, and the | |
| 5 | + reconciler does not know what is under it — so the same tree that egui paints | |
| 6 | + as a window goes through `libjolttui` as cells instead. Nothing in `frq.app` | |
| 7 | + changes; this namespace only picks the other backend and gives it a screen. | |
| 8 | + | |
| 9 | + Requiring order is the whole trick. `frq.app` pulls in `glimmer-vidya.core`, | |
| 10 | + which installs itself on load; `glimmer-tui.core` is required after it and | |
| 11 | + installs itself in turn, so the backend glimmer renders with is the terminal. | |
| 12 | + The Vidya library is still loaded and frq still calls into it — for a window | |
| 13 | + title, a window width, a picture chooser — and with no window open every one | |
| 14 | + of those is inert, which is the behaviour those calls already have to have. | |
| 15 | + | |
| 16 | + Two ways to run it: | |
| 17 | + | |
| 18 | + jolt -M:tui the real terminal, until Ctrl-Q | |
| 19 | + jolt -M:tui --headless one screenshot on stdout, no terminal at all | |
| 20 | + | |
| 21 | + The headless one is `tui_headless` — the same layout and the same painting | |
| 22 | + with the writer taken off the end — and is what a screenshot in a bug report | |
| 23 | + or a CI check should be." | |
| 24 | + (:require [clojure.string :as str] | |
| 25 | + [frq.app :as app] | |
| 26 | + [frq.state :as s] | |
| 27 | + [glimmer.core :as ui] | |
| 28 | + ;; last, so its install! is the one that stands | |
| 29 | + [glimmer-tui.core :as tui])) | |
| 30 | + | |
| 31 | +(def ^:private demo-channel "#tui") | |
| 32 | + | |
| 33 | +;; frq's spacing is written in points, because it was written for a window: | |
| 34 | +;; `:margin 12`, `:width-request 260`, a sidebar of 320. A cell is worth about | |
| 35 | +;; eight of those across, so that is the divisor the backend is handed — and | |
| 36 | +;; the same one `narrow!` multiplies back up, so `wide?` is asked its question | |
| 37 | +;; in the units it was written against. | |
| 38 | +(def ^:private points-per-cell 8) | |
| 39 | + | |
| 40 | +(defn- seed! | |
| 41 | + "Put a conversation on screen for a client that is not connected to anything. | |
| 42 | + | |
| 43 | + A terminal session started for a look at the layout has no server behind it, | |
| 44 | + and an empty buffer says nothing about how the buffer looks. These go in | |
| 45 | + through `push-message!` rather than into the atom directly, so what is drawn | |
| 46 | + is a real buffer — timestamps, day headings, the actor lookup, reply chips | |
| 47 | + and reactions all included." | |
| 48 | + [] | |
| 49 | + (let [now (System/currentTimeMillis) | |
| 50 | + minute 60000] | |
| 51 | + (s/push-message! demo-channel "*" (str "Now talking in " demo-channel) | |
| 52 | + {:at (- now (* 32 minute))}) | |
| 53 | + (s/push-message! demo-channel "nandi" "the tree ABI is the same one libvidya exports" | |
| 54 | + {:at (- now (* 12 minute)) :id "m1"}) | |
| 55 | + (s/push-message! demo-channel "vidya" "so the jolt side picks a window or a terminal and changes nothing else" | |
| 56 | + {:at (- now (* 11 minute)) :id "m2" :reply-to "m1" | |
| 57 | + :reactions {"👍" ["nandi"]}}) | |
| 58 | + (s/push-message! demo-channel "nandi" "one reconciler, two shared objects" | |
| 59 | + {:at (- now (* 2 minute)) :id "m3"})) | |
| 60 | + (swap! s/channels assoc-in [demo-channel :joined?] true) | |
| 61 | + (swap! s/channels assoc-in [demo-channel :users] | |
| 62 | + {"nandi" "@" "vidya" "" "you" ""}) | |
| 63 | + (reset! s/form-nick "you") | |
| 64 | + (reset! s/status (str "Not connected — " demo-channel " is a demo buffer")) | |
| 65 | + (reset! s/current demo-channel) | |
| 66 | + (reset! s/screen :chat) | |
| 67 | + nil) | |
| 68 | + | |
| 69 | +(defn- narrow! | |
| 70 | + "Tell the layout how much room it has, in the units it expects. | |
| 71 | + | |
| 72 | + `frq.state`'s `wide?` is written against a window's width in points, and the | |
| 73 | + terminal's is in cells — two orders of magnitude smaller, so left alone every | |
| 74 | + session would take the narrow layout by accident rather than on purpose. A | |
| 75 | + cell is about eight points wide and a row about sixteen tall, which is close | |
| 76 | + enough for the one question anything here asks of these numbers." | |
| 77 | + [] | |
| 78 | + (let [[cols rows] (tui/screen-size)] | |
| 79 | + (reset! s/window-width (* cols points-per-cell)) | |
| 80 | + ;; A row is worth about twice a column: a cell is taller than it is wide, | |
| 81 | + ;; and the heights in frq — a picture's, the space kept for the compose bar | |
| 82 | + ;; — are measured against the window's. | |
| 83 | + (reset! s/window-height (* rows points-per-cell 2))) | |
| 84 | + nil) | |
| 85 | + | |
| 86 | +(defn- run-headless! | |
| 87 | + "Mount the app in a session of `cols` by `rows` with no terminal, let it | |
| 88 | + settle, and print what was painted." | |
| 89 | + [cols rows] | |
| 90 | + ;; The size has to be known before the first render, or the layout reads the | |
| 91 | + ;; zero it was left at. A timer runs on the loop thread, which is where | |
| 92 | + ;; screen-size may be asked — and the first one fires before the first paint. | |
| 93 | + (tui/after! 0 narrow!) | |
| 94 | + (tui/after! 120 (fn [] | |
| 95 | + (println (tui/screen-str)) | |
| 96 | + (tui/quit!))) | |
| 97 | + (ui/run app/app :headless [cols rows] :points-per-cell points-per-cell)) | |
| 98 | + | |
| 99 | +(defn- run-terminal! [] | |
| 100 | + (tui/after! 0 narrow!) | |
| 101 | + ;; The terminal is resized by dragging its edge, and only a poll notices: | |
| 102 | + ;; the backend reports a size, and only what a component derefs re-renders. | |
| 103 | + (tui/every! 200 narrow!) | |
| 104 | + (ui/run app/app :mouse true :points-per-cell points-per-cell)) | |
| 105 | + | |
| 106 | +(defn -main [& args] | |
| 107 | + (let [args (set args) | |
| 108 | + headless? (contains? args "--headless") | |
| 109 | + cols (if-let [n (first (keep #(when (str/starts-with? % "--cols=") | |
| 110 | + (subs % 7)) | |
| 111 | + args))] | |
| 112 | + (Integer/parseInt n) | |
| 113 | + 100) | |
| 114 | + rows (if-let [n (first (keep #(when (str/starts-with? % "--rows=") | |
| 115 | + (subs % 7)) | |
| 116 | + args))] | |
| 117 | + (Integer/parseInt n) | |
| 118 | + 36)] | |
| 119 | + (s/restore-prefs!) | |
| 120 | + (seed!) | |
| 121 | + (if headless? | |
| 122 | + (run-headless! cols rows) | |
| 123 | + (run-terminal!)))) | |
| new file mode 100644 | |||
| @@ -0,0 +1,123 @@ | |||
| 1 | +(ns frq.tui | ||
| 2 | + "frq's own screens, painted into a terminal. | ||
| 3 | + | ||
| 4 | + The components in `frq.app` are hiccup over glimmer's reconciler, and the | ||
| 5 | + reconciler does not know what is under it — so the same tree that egui paints | ||
| 6 | + as a window goes through `libjolttui` as cells instead. Nothing in `frq.app` | ||
| 7 | + changes; this namespace only picks the other backend and gives it a screen. | ||
| 8 | + | ||
| 9 | + Requiring order is the whole trick. `frq.app` pulls in `glimmer-vidya.core`, | ||
| 10 | + which installs itself on load; `glimmer-tui.core` is required after it and | ||
| 11 | + installs itself in turn, so the backend glimmer renders with is the terminal. | ||
| 12 | + The Vidya library is still loaded and frq still calls into it — for a window | ||
| 13 | + title, a window width, a picture chooser — and with no window open every one | ||
| 14 | + of those is inert, which is the behaviour those calls already have to have. | ||
| 15 | + | ||
| 16 | + Two ways to run it: | ||
| 17 | + | ||
| 18 | + jolt -M:tui the real terminal, until Ctrl-Q | ||
| 19 | + jolt -M:tui --headless one screenshot on stdout, no terminal at all | ||
| 20 | + | ||
| 21 | + The headless one is `tui_headless` — the same layout and the same painting | ||
| 22 | + with the writer taken off the end — and is what a screenshot in a bug report | ||
| 23 | + or a CI check should be." | ||
| 24 | + (:require [clojure.string :as str] | ||
| 25 | + [frq.app :as app] | ||
| 26 | + [frq.state :as s] | ||
| 27 | + [glimmer.core :as ui] | ||
| 28 | + ;; last, so its install! is the one that stands | ||
| 29 | + [glimmer-tui.core :as tui])) | ||
| 30 | + | ||
| 31 | +(def ^:private demo-channel "#tui") | ||
| 32 | + | ||
| 33 | +;; frq's spacing is written in points, because it was written for a window: | ||
| 34 | +;; `:margin 12`, `:width-request 260`, a sidebar of 320. A cell is worth about | ||
| 35 | +;; eight of those across, so that is the divisor the backend is handed — and | ||
| 36 | +;; the same one `narrow!` multiplies back up, so `wide?` is asked its question | ||
| 37 | +;; in the units it was written against. | ||
| 38 | +(def ^:private points-per-cell 8) | ||
| 39 | + | ||
| 40 | +(defn- seed! | ||
| 41 | + "Put a conversation on screen for a client that is not connected to anything. | ||
| 42 | + | ||
| 43 | + A terminal session started for a look at the layout has no server behind it, | ||
| 44 | + and an empty buffer says nothing about how the buffer looks. These go in | ||
| 45 | + through `push-message!` rather than into the atom directly, so what is drawn | ||
| 46 | + is a real buffer — timestamps, day headings, the actor lookup, reply chips | ||
| 47 | + and reactions all included." | ||
| 48 | + [] | ||
| 49 | + (let [now (System/currentTimeMillis) | ||
| 50 | + minute 60000] | ||
| 51 | + (s/push-message! demo-channel "*" (str "Now talking in " demo-channel) | ||
| 52 | + {:at (- now (* 32 minute))}) | ||
| 53 | + (s/push-message! demo-channel "nandi" "the tree ABI is the same one libvidya exports" | ||
| 54 | + {:at (- now (* 12 minute)) :id "m1"}) | ||
| 55 | + (s/push-message! demo-channel "vidya" "so the jolt side picks a window or a terminal and changes nothing else" | ||
| 56 | + {:at (- now (* 11 minute)) :id "m2" :reply-to "m1" | ||
| 57 | + :reactions {"👍" ["nandi"]}}) | ||
| 58 | + (s/push-message! demo-channel "nandi" "one reconciler, two shared objects" | ||
| 59 | + {:at (- now (* 2 minute)) :id "m3"})) | ||
| 60 | + (swap! s/channels assoc-in [demo-channel :joined?] true) | ||
| 61 | + (swap! s/channels assoc-in [demo-channel :users] | ||
| 62 | + {"nandi" "@" "vidya" "" "you" ""}) | ||
| 63 | + (reset! s/form-nick "you") | ||
| 64 | + (reset! s/status (str "Not connected — " demo-channel " is a demo buffer")) | ||
| 65 | + (reset! s/current demo-channel) | ||
| 66 | + (reset! s/screen :chat) | ||
| 67 | + nil) | ||
| 68 | + | ||
| 69 | +(defn- narrow! | ||
| 70 | + "Tell the layout how much room it has, in the units it expects. | ||
| 71 | + | ||
| 72 | + `frq.state`'s `wide?` is written against a window's width in points, and the | ||
| 73 | + terminal's is in cells — two orders of magnitude smaller, so left alone every | ||
| 74 | + session would take the narrow layout by accident rather than on purpose. A | ||
| 75 | + cell is about eight points wide and a row about sixteen tall, which is close | ||
| 76 | + enough for the one question anything here asks of these numbers." | ||
| 77 | + [] | ||
| 78 | + (let [[cols rows] (tui/screen-size)] | ||
| 79 | + (reset! s/window-width (* cols points-per-cell)) | ||
| 80 | + ;; A row is worth about twice a column: a cell is taller than it is wide, | ||
| 81 | + ;; and the heights in frq — a picture's, the space kept for the compose bar | ||
| 82 | + ;; — are measured against the window's. | ||
| 83 | + (reset! s/window-height (* rows points-per-cell 2))) | ||
| 84 | + nil) | ||
| 85 | + | ||
| 86 | +(defn- run-headless! | ||
| 87 | + "Mount the app in a session of `cols` by `rows` with no terminal, let it | ||
| 88 | + settle, and print what was painted." | ||
| 89 | + [cols rows] | ||
| 90 | + ;; The size has to be known before the first render, or the layout reads the | ||
| 91 | + ;; zero it was left at. A timer runs on the loop thread, which is where | ||
| 92 | + ;; screen-size may be asked — and the first one fires before the first paint. | ||
| 93 | + (tui/after! 0 narrow!) | ||
| 94 | + (tui/after! 120 (fn [] | ||
| 95 | + (println (tui/screen-str)) | ||
| 96 | + (tui/quit!))) | ||
| 97 | + (ui/run app/app :headless [cols rows] :points-per-cell points-per-cell)) | ||
| 98 | + | ||
| 99 | +(defn- run-terminal! [] | ||
| 100 | + (tui/after! 0 narrow!) | ||
| 101 | + ;; The terminal is resized by dragging its edge, and only a poll notices: | ||
| 102 | + ;; the backend reports a size, and only what a component derefs re-renders. | ||
| 103 | + (tui/every! 200 narrow!) | ||
| 104 | + (ui/run app/app :mouse true :points-per-cell points-per-cell)) | ||
| 105 | + | ||
| 106 | +(defn -main [& args] | ||
| 107 | + (let [args (set args) | ||
| 108 | + headless? (contains? args "--headless") | ||
| 109 | + cols (if-let [n (first (keep #(when (str/starts-with? % "--cols=") | ||
| 110 | + (subs % 7)) | ||
| 111 | + args))] | ||
| 112 | + (Integer/parseInt n) | ||
| 113 | + 100) | ||
| 114 | + rows (if-let [n (first (keep #(when (str/starts-with? % "--rows=") | ||
| 115 | + (subs % 7)) | ||
| 116 | + args))] | ||
| 117 | + (Integer/parseInt n) | ||
| 118 | + 36)] | ||
| 119 | + (s/restore-prefs!) | ||
| 120 | + (seed!) | ||
| 121 | + (if headless? | ||
| 122 | + (run-headless! cols rows) | ||
| 123 | + (run-terminal!)))) | ||