Paint the same tree into a terminal, for the machines with no window
glimmer's reconciler needs widgets to patch, and a terminal has none — the same problem egui posed, and the same answer: the node arena lives in Rust, the caller mutates it by handle, and nothing is drawn until the frame call walks it. So libjolttui exports the tree ABI libvidya already does, and the jolt side chooses a window or a terminal by naming a different object. Painting goes through a grid of cells and only term.rs turns one into an escape sequence, which is what makes tui_headless the same code path with the writer taken off the end: 48 tests mount real nodes, type real keys and assert on the rows that came out, with no TTY anywhere. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
a7f6202 parent: a9f3673 modified
BUCK +8 -1 | @@ -8,6 +8,13 @@ genrule( | ||
| 8 | 8 | visibility = ["PUBLIC"], |
| 9 | 9 | ) |
| 10 | 10 | |
| 11 | +genrule( | |
| 12 | + name = "libjolttui", | |
| 13 | + out = "libjolttui.so", | |
| 14 | + cmd = "cp $(location //crates/jolt-tui:jolt-tui[shared]) $OUT", | |
| 15 | + visibility = ["PUBLIC"], | |
| 16 | +) | |
| 17 | + | |
| 11 | 18 | genrule( |
| 12 | 19 | name = "libjoltmoq", |
| 13 | 20 | out = "libjoltmoq.so", |
| @@ -17,7 +24,7 @@ genrule( | ||
| 17 | 24 | |
| 18 | 25 | filegroup( |
| 19 | 26 | name = "libs", |
| 20 | - srcs = [":libjoltmoq", ":libvidya"], | |
| 27 | + srcs = [":libjoltmoq", ":libjolttui", ":libvidya"], | |
| 21 | 28 | visibility = ["PUBLIC"], |
| 22 | 29 | ) |
| 23 | 30 | |
| @@ -8,6 +8,13 @@ genrule( | |||
| 8 | visibility = ["PUBLIC"], | 8 | visibility = ["PUBLIC"], |
| 9 | ) | 9 | ) |
| 10 | 10 | ||
| 11 | +genrule( | ||
| 12 | + name = "libjolttui", | ||
| 13 | + out = "libjolttui.so", | ||
| 14 | + cmd = "cp $(location //crates/jolt-tui:jolt-tui[shared]) $OUT", | ||
| 15 | + visibility = ["PUBLIC"], | ||
| 16 | +) | ||
| 17 | + | ||
| 11 | genrule( | 18 | genrule( |
| 12 | name = "libjoltmoq", | 19 | name = "libjoltmoq", |
| 13 | out = "libjoltmoq.so", | 20 | out = "libjoltmoq.so", |
| @@ -17,7 +24,7 @@ genrule( | |||
| 17 | 24 | ||
| 18 | filegroup( | 25 | filegroup( |
| 19 | name = "libs", | 26 | name = "libs", |
| 20 | - srcs = [":libjoltmoq", ":libvidya"], | 27 | + srcs = [":libjoltmoq", ":libjolttui", ":libvidya"], |
| 21 | visibility = ["PUBLIC"], | 28 | visibility = ["PUBLIC"], |
| 22 | ) | 29 | ) |
| 23 | 30 | ||
modified
Cargo.lock +45 -0 | @@ -1164,6 +1164,20 @@ version = "0.8.22" | ||
| 1164 | 1164 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 1165 | 1165 | checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" |
| 1166 | 1166 | |
| 1167 | +[[package]] | |
| 1168 | +name = "crossterm" | |
| 1169 | +version = "0.28.1" | |
| 1170 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1171 | +checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" | |
| 1172 | +dependencies = [ | |
| 1173 | + "bitflags 2.13.1", | |
| 1174 | + "mio", | |
| 1175 | + "parking_lot", | |
| 1176 | + "rustix 0.38.44", | |
| 1177 | + "signal-hook", | |
| 1178 | + "signal-hook-mio", | |
| 1179 | +] | |
| 1180 | + | |
| 1167 | 1181 | [[package]] |
| 1168 | 1182 | name = "crunchy" |
| 1169 | 1183 | version = "0.2.4" |
| @@ -3258,6 +3272,15 @@ dependencies = [ | ||
| 3258 | 3272 | "v4l2r", |
| 3259 | 3273 | ] |
| 3260 | 3274 | |
| 3275 | +[[package]] | |
| 3276 | +name = "jolt-tui" | |
| 3277 | +version = "0.1.0" | |
| 3278 | +dependencies = [ | |
| 3279 | + "crossterm", | |
| 3280 | + "jolt-abi", | |
| 3281 | + "log", | |
| 3282 | +] | |
| 3283 | + | |
| 3261 | 3284 | [[package]] |
| 3262 | 3285 | name = "js-sys" |
| 3263 | 3286 | version = "0.3.103" |
| @@ -3491,6 +3514,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 3491 | 3514 | checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" |
| 3492 | 3515 | dependencies = [ |
| 3493 | 3516 | "libc", |
| 3517 | + "log", | |
| 3494 | 3518 | "wasi", |
| 3495 | 3519 | "windows-sys 0.61.2", |
| 3496 | 3520 | ] |
| @@ -6019,6 +6043,27 @@ version = "2.0.1" | ||
| 6019 | 6043 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 6020 | 6044 | checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" |
| 6021 | 6045 | |
| 6046 | +[[package]] | |
| 6047 | +name = "signal-hook" | |
| 6048 | +version = "0.3.18" | |
| 6049 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 6050 | +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" | |
| 6051 | +dependencies = [ | |
| 6052 | + "libc", | |
| 6053 | + "signal-hook-registry", | |
| 6054 | +] | |
| 6055 | + | |
| 6056 | +[[package]] | |
| 6057 | +name = "signal-hook-mio" | |
| 6058 | +version = "0.2.5" | |
| 6059 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 6060 | +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" | |
| 6061 | +dependencies = [ | |
| 6062 | + "libc", | |
| 6063 | + "mio", | |
| 6064 | + "signal-hook", | |
| 6065 | +] | |
| 6066 | + | |
| 6022 | 6067 | [[package]] |
| 6023 | 6068 | name = "signal-hook-registry" |
| 6024 | 6069 | version = "1.4.8" |
| @@ -1164,6 +1164,20 @@ version = "0.8.22" | |||
| 1164 | source = "registry+https://github.com/rust-lang/crates.io-index" | 1164 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 1165 | checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" | 1165 | checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" |
| 1166 | 1166 | ||
| 1167 | +[[package]] | ||
| 1168 | +name = "crossterm" | ||
| 1169 | +version = "0.28.1" | ||
| 1170 | +source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1171 | +checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" | ||
| 1172 | +dependencies = [ | ||
| 1173 | + "bitflags 2.13.1", | ||
| 1174 | + "mio", | ||
| 1175 | + "parking_lot", | ||
| 1176 | + "rustix 0.38.44", | ||
| 1177 | + "signal-hook", | ||
| 1178 | + "signal-hook-mio", | ||
| 1179 | +] | ||
| 1180 | + | ||
| 1167 | [[package]] | 1181 | [[package]] |
| 1168 | name = "crunchy" | 1182 | name = "crunchy" |
| 1169 | version = "0.2.4" | 1183 | version = "0.2.4" |
| @@ -3258,6 +3272,15 @@ dependencies = [ | |||
| 3258 | "v4l2r", | 3272 | "v4l2r", |
| 3259 | ] | 3273 | ] |
| 3260 | 3274 | ||
| 3275 | +[[package]] | ||
| 3276 | +name = "jolt-tui" | ||
| 3277 | +version = "0.1.0" | ||
| 3278 | +dependencies = [ | ||
| 3279 | + "crossterm", | ||
| 3280 | + "jolt-abi", | ||
| 3281 | + "log", | ||
| 3282 | +] | ||
| 3283 | + | ||
| 3261 | [[package]] | 3284 | [[package]] |
| 3262 | name = "js-sys" | 3285 | name = "js-sys" |
| 3263 | version = "0.3.103" | 3286 | version = "0.3.103" |
| @@ -3491,6 +3514,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| 3491 | checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" | 3514 | checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" |
| 3492 | dependencies = [ | 3515 | dependencies = [ |
| 3493 | "libc", | 3516 | "libc", |
| 3517 | + "log", | ||
| 3494 | "wasi", | 3518 | "wasi", |
| 3495 | "windows-sys 0.61.2", | 3519 | "windows-sys 0.61.2", |
| 3496 | ] | 3520 | ] |
| @@ -6019,6 +6043,27 @@ version = "2.0.1" | |||
| 6019 | source = "registry+https://github.com/rust-lang/crates.io-index" | 6043 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 6020 | checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" | 6044 | checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" |
| 6021 | 6045 | ||
| 6046 | +[[package]] | ||
| 6047 | +name = "signal-hook" | ||
| 6048 | +version = "0.3.18" | ||
| 6049 | +source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 6050 | +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" | ||
| 6051 | +dependencies = [ | ||
| 6052 | + "libc", | ||
| 6053 | + "signal-hook-registry", | ||
| 6054 | +] | ||
| 6055 | + | ||
| 6056 | +[[package]] | ||
| 6057 | +name = "signal-hook-mio" | ||
| 6058 | +version = "0.2.5" | ||
| 6059 | +source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 6060 | +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" | ||
| 6061 | +dependencies = [ | ||
| 6062 | + "libc", | ||
| 6063 | + "mio", | ||
| 6064 | + "signal-hook", | ||
| 6065 | +] | ||
| 6066 | + | ||
| 6022 | [[package]] | 6067 | [[package]] |
| 6023 | name = "signal-hook-registry" | 6068 | name = "signal-hook-registry" |
| 6024 | version = "1.4.8" | 6069 | version = "1.4.8" |
modified
Cargo.toml +1 -0 | @@ -21,6 +21,7 @@ resolver = "2" | ||
| 21 | 21 | members = [ |
| 22 | 22 | "crates/jolt-abi", |
| 23 | 23 | "crates/jolt-moq", |
| 24 | + "crates/jolt-tui", | |
| 24 | 25 | "crates/jolt-vidya", |
| 25 | 26 | "crates/vidya-core", |
| 26 | 27 | ] |
| @@ -21,6 +21,7 @@ resolver = "2" | |||
| 21 | members = [ | 21 | members = [ |
| 22 | "crates/jolt-abi", | 22 | "crates/jolt-abi", |
| 23 | "crates/jolt-moq", | 23 | "crates/jolt-moq", |
| 24 | + "crates/jolt-tui", | ||
| 24 | "crates/jolt-vidya", | 25 | "crates/jolt-vidya", |
| 25 | "crates/vidya-core", | 26 | "crates/vidya-core", |
| 26 | ] | 27 | ] |
modified
README.md +2 -1 | @@ -13,11 +13,12 @@ anything else would mean writing them badly. | ||
| 13 | 13 | crates/jolt-abi the rules every library here keeps at its edge |
| 14 | 14 | crates/jolt-vidya the retained-tree C ABI → libvidya.so |
| 15 | 15 | crates/vidya-core the egui semantic layer behind it (theme, widgets, fonts) |
| 16 | +crates/jolt-tui the same tree ABI, painted into a terminal → libjolttui.so | |
| 16 | 17 | crates/jolt-moq freeq's AV media plane → libjoltmoq.so |
| 17 | 18 | jolt/glimmer-vidya the jolt side of libvidya: glimmer's backend |
| 18 | 19 | ``` |
| 19 | 20 | |
| 20 | -Both objects land in one `target/release`, so a consumer points | |
| 21 | +All three objects land in one `target/release`, so a consumer points | |
| 21 | 22 | `LD_LIBRARY_PATH` at one directory and names in `:jolt/native` only the ones it |
| 22 | 23 | actually wants. |
| 23 | 24 | |
| @@ -13,11 +13,12 @@ anything else would mean writing them badly. | |||
| 13 | crates/jolt-abi the rules every library here keeps at its edge | 13 | crates/jolt-abi the rules every library here keeps at its edge |
| 14 | crates/jolt-vidya the retained-tree C ABI → libvidya.so | 14 | crates/jolt-vidya the retained-tree C ABI → libvidya.so |
| 15 | crates/vidya-core the egui semantic layer behind it (theme, widgets, fonts) | 15 | crates/vidya-core the egui semantic layer behind it (theme, widgets, fonts) |
| 16 | +crates/jolt-tui the same tree ABI, painted into a terminal → libjolttui.so | ||
| 16 | crates/jolt-moq freeq's AV media plane → libjoltmoq.so | 17 | crates/jolt-moq freeq's AV media plane → libjoltmoq.so |
| 17 | jolt/glimmer-vidya the jolt side of libvidya: glimmer's backend | 18 | jolt/glimmer-vidya the jolt side of libvidya: glimmer's backend |
| 18 | ``` | 19 | ``` |
| 19 | 20 | ||
| 20 | -Both objects land in one `target/release`, so a consumer points | 21 | +All three objects land in one `target/release`, so a consumer points |
| 21 | `LD_LIBRARY_PATH` at one directory and names in `:jolt/native` only the ones it | 22 | `LD_LIBRARY_PATH` at one directory and names in `:jolt/native` only the ones it |
| 22 | actually wants. | 23 | actually wants. |
| 23 | 24 | ||
added
crates/jolt-tui/BUCK +28 -0 | new file mode 100644 | ||
| @@ -0,0 +1,28 @@ | ||
| 1 | +# The C ABI Jolt loads for a terminal UI: libjolttui.so. | |
| 2 | +# | |
| 3 | +# The same shape as //crates/jolt-vidya, and for the same reason: one glimmer | |
| 4 | +# backend on the jolt side, and a choice of shared object underneath it. | |
| 5 | +rust_library( | |
| 6 | + name = "jolt-tui", | |
| 7 | + srcs = glob(["src/**/*.rs"]), | |
| 8 | + crate = "jolttui", | |
| 9 | + crate_root = "src/lib.rs", | |
| 10 | + edition = "2021", | |
| 11 | + features = ["terminal"], | |
| 12 | + preferred_linkage = "shared", | |
| 13 | + # Without this buck names the library after the target — lib_jolt-tui.so — | |
| 14 | + # and that name, not the filename, is what a dependent's DT_NEEDED gets. | |
| 15 | + soname = "libjolttui.so", | |
| 16 | + # As on the vidya cdylib: cross-crate inlining across the graph, one codegen | |
| 17 | + # unit so the dedup is not fighting parallelism inside the crate. | |
| 18 | + rustc_flags = [ | |
| 19 | + "-Clto=fat", | |
| 20 | + "-Ccodegen-units=1", | |
| 21 | + ], | |
| 22 | + visibility = ["PUBLIC"], | |
| 23 | + deps = [ | |
| 24 | + "//crates/jolt-abi:jolt-abi", | |
| 25 | + "//third-party/rust:crossterm", | |
| 26 | + "//third-party/rust:log", | |
| 27 | + ], | |
| 28 | +) | |
| new file mode 100644 | |||
| @@ -0,0 +1,28 @@ | |||
| 1 | +# The C ABI Jolt loads for a terminal UI: libjolttui.so. | ||
| 2 | +# | ||
| 3 | +# The same shape as //crates/jolt-vidya, and for the same reason: one glimmer | ||
| 4 | +# backend on the jolt side, and a choice of shared object underneath it. | ||
| 5 | +rust_library( | ||
| 6 | + name = "jolt-tui", | ||
| 7 | + srcs = glob(["src/**/*.rs"]), | ||
| 8 | + crate = "jolttui", | ||
| 9 | + crate_root = "src/lib.rs", | ||
| 10 | + edition = "2021", | ||
| 11 | + features = ["terminal"], | ||
| 12 | + preferred_linkage = "shared", | ||
| 13 | + # Without this buck names the library after the target — lib_jolt-tui.so — | ||
| 14 | + # and that name, not the filename, is what a dependent's DT_NEEDED gets. | ||
| 15 | + soname = "libjolttui.so", | ||
| 16 | + # As on the vidya cdylib: cross-crate inlining across the graph, one codegen | ||
| 17 | + # unit so the dedup is not fighting parallelism inside the crate. | ||
| 18 | + rustc_flags = [ | ||
| 19 | + "-Clto=fat", | ||
| 20 | + "-Ccodegen-units=1", | ||
| 21 | + ], | ||
| 22 | + visibility = ["PUBLIC"], | ||
| 23 | + deps = [ | ||
| 24 | + "//crates/jolt-abi:jolt-abi", | ||
| 25 | + "//third-party/rust:crossterm", | ||
| 26 | + "//third-party/rust:log", | ||
| 27 | + ], | ||
| 28 | +) | ||
added
crates/jolt-tui/Cargo.toml +26 -0 | new file mode 100644 | ||
| @@ -0,0 +1,26 @@ | ||
| 1 | +[package] | |
| 2 | +name = "jolt-tui" | |
| 3 | +version.workspace = true | |
| 4 | +edition.workspace = true | |
| 5 | +license.workspace = true | |
| 6 | +description = "glimmer's terminal backend behind a C ABI" | |
| 7 | +publish = false | |
| 8 | + | |
| 9 | +# Produces `libjolttui.so`. The retained-tree ABI `libvidya` exports, painted | |
| 10 | +# into a terminal instead of a window — so the jolt side picks a backend by | |
| 11 | +# naming a different shared object. | |
| 12 | +[lib] | |
| 13 | +name = "jolttui" | |
| 14 | +crate-type = ["cdylib", "rlib"] | |
| 15 | + | |
| 16 | +[features] | |
| 17 | +# On by default; without it the crate still builds the tree, the layout and the | |
| 18 | +# painter, and `tui_headless` still works. That is what CI and the test suite | |
| 19 | +# use, and it is why a machine with no TTY can still build and check this. | |
| 20 | +default = ["terminal"] | |
| 21 | +terminal = ["dep:crossterm"] | |
| 22 | + | |
| 23 | +[dependencies] | |
| 24 | +jolt-abi.workspace = true | |
| 25 | +log.workspace = true | |
| 26 | +crossterm = { version = "0.28", optional = true, default-features = false, features = ["events"] } | |
| new file mode 100644 | |||
| @@ -0,0 +1,26 @@ | |||
| 1 | +[package] | ||
| 2 | +name = "jolt-tui" | ||
| 3 | +version.workspace = true | ||
| 4 | +edition.workspace = true | ||
| 5 | +license.workspace = true | ||
| 6 | +description = "glimmer's terminal backend behind a C ABI" | ||
| 7 | +publish = false | ||
| 8 | + | ||
| 9 | +# Produces `libjolttui.so`. The retained-tree ABI `libvidya` exports, painted | ||
| 10 | +# into a terminal instead of a window — so the jolt side picks a backend by | ||
| 11 | +# naming a different shared object. | ||
| 12 | +[lib] | ||
| 13 | +name = "jolttui" | ||
| 14 | +crate-type = ["cdylib", "rlib"] | ||
| 15 | + | ||
| 16 | +[features] | ||
| 17 | +# On by default; without it the crate still builds the tree, the layout and the | ||
| 18 | +# painter, and `tui_headless` still works. That is what CI and the test suite | ||
| 19 | +# use, and it is why a machine with no TTY can still build and check this. | ||
| 20 | +default = ["terminal"] | ||
| 21 | +terminal = ["dep:crossterm"] | ||
| 22 | + | ||
| 23 | +[dependencies] | ||
| 24 | +jolt-abi.workspace = true | ||
| 25 | +log.workspace = true | ||
| 26 | +crossterm = { version = "0.28", optional = true, default-features = false, features = ["events"] } | ||
added
crates/jolt-tui/README.md +106 -0 | new file mode 100644 | ||
| @@ -0,0 +1,106 @@ | ||
| 1 | +# jolt-tui | |
| 2 | + | |
| 3 | +glimmer's **terminal** backend, behind the retained-tree C ABI — `libjolttui.so`. | |
| 4 | + | |
| 5 | +[glimmer-tui](https://github.com/jolt-lang/glimmer-tui) is the design this | |
| 6 | +follows: the same tags, the same props, the same keyboard, and the same rule | |
| 7 | +that painting goes through a grid so a test needs no terminal. What moves is | |
| 8 | +where the widget layer lives. There it is jolt over ncurses; here it is Rust | |
| 9 | +behind the ABI `libvidya` already exports, so the jolt side picks a GPU window | |
| 10 | +or a terminal by naming a different shared object and changing nothing else. | |
| 11 | + | |
| 12 | +``` | |
| 13 | + glimmer (reactive cells, components, the reconciler) | |
| 14 | + │ | |
| 15 | + ┌────────────────┴────────────────┐ | |
| 16 | + libvidya.so libjolttui.so | |
| 17 | + egui, a GPU window a terminal, in cells | |
| 18 | +``` | |
| 19 | + | |
| 20 | +## Why the tree is down here | |
| 21 | + | |
| 22 | +A reconciler needs widgets to patch. A terminal has none — it has a grid you | |
| 23 | +overwrite — so the same problem egui poses turns up again, and gets the same | |
| 24 | +answer: the node arena lives in Rust, the caller mutates it by integer handle, | |
| 25 | +and nothing is drawn until `tui_frame` walks the whole tree at once. | |
| 26 | + | |
| 27 | +Two things follow, both of them the reason for the arrangement rather than | |
| 28 | +accidents of it: | |
| 29 | + | |
| 30 | +* **FFI traffic tracks edits, not frames.** A screen that is not changing costs | |
| 31 | + no crossings; only what the reconciler actually changed is sent. | |
| 32 | +* **Painting is testable.** Everything above `term.rs` writes into a `Screen` — | |
| 33 | + a grid of styled cells — and only `term.rs` emits an escape sequence. | |
| 34 | + `tui_headless` opens a session with no terminal at all, `tui_feed_key` types | |
| 35 | + into it, and `tui_screen_line` reads back what was painted. That is not a | |
| 36 | + second implementation for tests; it is the same code path with the writer | |
| 37 | + taken off the end. | |
| 38 | + | |
| 39 | +**Handlers do not cross the boundary.** A node reports that it was clicked and | |
| 40 | +the caller looks up whose `:on-click` that was, exactly as with libvidya. | |
| 41 | + | |
| 42 | +## The ABI | |
| 43 | + | |
| 44 | +[`include/jolttui.h`](include/jolttui.h) is the reference: tags, props, events, | |
| 45 | +key names, and the borrowed-string rule. In outline: | |
| 46 | + | |
| 47 | +| | | | |
| 48 | +|---|---| | |
| 49 | +| session | `tui_open` / `tui_headless` / `tui_close`, `tui_should_close`, `tui_quit` | | |
| 50 | +| loop | `tui_tick(timeout_ms)` handles input, `tui_frame` lays out, paints and flushes | | |
| 51 | +| nodes | `tui_node_new` / `_free` / `_append` / `_remove` / `_insert_after` / `_replace` | | |
| 52 | +| props | `tui_node_set_str` / `_num` / `_bool`, and the matching reads | | |
| 53 | +| reading back | `tui_node_tag`, `tui_node_child_at`, `tui_tree_dump`, `tui_screen_line` | | |
| 54 | +| events | `tui_tree_poll_event` and the four accessors | | |
| 55 | +| input by hand | `tui_feed_key` / `_click` / `_wheel`, `tui_focus` | | |
| 56 | + | |
| 57 | +## Keyboard | |
| 58 | + | |
| 59 | +Focus is a ring in paint order over the widgets that can take it — buttons, | |
| 60 | +checkbuttons, entries, listboxes. Tab and Shift-Tab walk it, `:autofocus` | |
| 61 | +claims it on the first frame, and a widget that is unmounted or turned | |
| 62 | +insensitive gives it up rather than stranding the focus on nothing. | |
| 63 | + | |
| 64 | +| | | | |
| 65 | +|---|---| | |
| 66 | +| Enter, Space | activate the focused widget | | |
| 67 | +| arrows, `j`/`k`, Page Up/Down, `g`/`G` | move a list's cursor | | |
| 68 | +| Ctrl-A/E, Ctrl-W, Ctrl-U/K, Alt-B/F, Home/End | readline editing in an entry | | |
| 69 | +| Esc | closes the topmost `:overlay` | | |
| 70 | +| Ctrl-C, Ctrl-Q | quit | | |
| 71 | + | |
| 72 | +Anything nothing here wanted comes out as a `key` event on the focused node, | |
| 73 | +named the way it is fed in — `"ctrl+u"`, `"page-down"`, `"f5"`. Bubbling it to a | |
| 74 | +container's `:on-key` belongs to the caller: it holds the handlers. | |
| 75 | + | |
| 76 | +## Layout | |
| 77 | + | |
| 78 | +Every node answers two sizes — natural and minimum. A box hands out the natural | |
| 79 | +ones when there is room, shrinks them proportionally toward the minimums when | |
| 80 | +there is not, and gives the surplus to whoever set `:hexpand` / `:vexpand`. | |
| 81 | +`:width-request` and `:height-request` are a floor on both, so asking for four | |
| 82 | +rows gets four rows even when space is short. | |
| 83 | + | |
| 84 | +## Building | |
| 85 | + | |
| 86 | +```bash | |
| 87 | +cargo build -p jolt-tui --release # target/release/libjolttui.so | |
| 88 | +cargo test -p jolt-tui # the whole widget layer, headless | |
| 89 | +cargo run -p jolt-tui --example showcase # every tag, in a terminal | |
| 90 | +``` | |
| 91 | + | |
| 92 | +`--no-default-features` drops crossterm and with it `tui_open` — the tree, the | |
| 93 | +layout, the painter and `tui_headless` all still work, which is what makes this | |
| 94 | +crate buildable and checkable on a machine with no terminal crate and no TTY. | |
| 95 | + | |
| 96 | +## Limits | |
| 97 | + | |
| 98 | +* **One session per process, on one thread.** The session lives in thread-local | |
| 99 | + storage, so a call from another thread is inert rather than unsound — the | |
| 100 | + same rule libvidya keeps. | |
| 101 | +* **One cell per character.** A wide CJK glyph or an emoji is measured as one | |
| 102 | + column and will crowd its neighbour. Widths are a table lookup away; nothing | |
| 103 | + in the design is in the way of adding one. | |
| 104 | +* **No `:table`, `:paginator` or `:help` yet.** glimmer-tui has them; a table is | |
| 105 | + a keyed listbox of rows here for now. Each is widget-layer work in | |
| 106 | + `src/paint.rs` plus a measure in `src/layout.rs` and a tag. | |
| new file mode 100644 | |||
| @@ -0,0 +1,106 @@ | |||
| 1 | +# jolt-tui | ||
| 2 | + | ||
| 3 | +glimmer's **terminal** backend, behind the retained-tree C ABI — `libjolttui.so`. | ||
| 4 | + | ||
| 5 | +[glimmer-tui](https://github.com/jolt-lang/glimmer-tui) is the design this | ||
| 6 | +follows: the same tags, the same props, the same keyboard, and the same rule | ||
| 7 | +that painting goes through a grid so a test needs no terminal. What moves is | ||
| 8 | +where the widget layer lives. There it is jolt over ncurses; here it is Rust | ||
| 9 | +behind the ABI `libvidya` already exports, so the jolt side picks a GPU window | ||
| 10 | +or a terminal by naming a different shared object and changing nothing else. | ||
| 11 | + | ||
| 12 | +``` | ||
| 13 | + glimmer (reactive cells, components, the reconciler) | ||
| 14 | + │ | ||
| 15 | + ┌────────────────┴────────────────┐ | ||
| 16 | + libvidya.so libjolttui.so | ||
| 17 | + egui, a GPU window a terminal, in cells | ||
| 18 | +``` | ||
| 19 | + | ||
| 20 | +## Why the tree is down here | ||
| 21 | + | ||
| 22 | +A reconciler needs widgets to patch. A terminal has none — it has a grid you | ||
| 23 | +overwrite — so the same problem egui poses turns up again, and gets the same | ||
| 24 | +answer: the node arena lives in Rust, the caller mutates it by integer handle, | ||
| 25 | +and nothing is drawn until `tui_frame` walks the whole tree at once. | ||
| 26 | + | ||
| 27 | +Two things follow, both of them the reason for the arrangement rather than | ||
| 28 | +accidents of it: | ||
| 29 | + | ||
| 30 | +* **FFI traffic tracks edits, not frames.** A screen that is not changing costs | ||
| 31 | + no crossings; only what the reconciler actually changed is sent. | ||
| 32 | +* **Painting is testable.** Everything above `term.rs` writes into a `Screen` — | ||
| 33 | + a grid of styled cells — and only `term.rs` emits an escape sequence. | ||
| 34 | + `tui_headless` opens a session with no terminal at all, `tui_feed_key` types | ||
| 35 | + into it, and `tui_screen_line` reads back what was painted. That is not a | ||
| 36 | + second implementation for tests; it is the same code path with the writer | ||
| 37 | + taken off the end. | ||
| 38 | + | ||
| 39 | +**Handlers do not cross the boundary.** A node reports that it was clicked and | ||
| 40 | +the caller looks up whose `:on-click` that was, exactly as with libvidya. | ||
| 41 | + | ||
| 42 | +## The ABI | ||
| 43 | + | ||
| 44 | +[`include/jolttui.h`](include/jolttui.h) is the reference: tags, props, events, | ||
| 45 | +key names, and the borrowed-string rule. In outline: | ||
| 46 | + | ||
| 47 | +| | | | ||
| 48 | +|---|---| | ||
| 49 | +| session | `tui_open` / `tui_headless` / `tui_close`, `tui_should_close`, `tui_quit` | | ||
| 50 | +| loop | `tui_tick(timeout_ms)` handles input, `tui_frame` lays out, paints and flushes | | ||
| 51 | +| nodes | `tui_node_new` / `_free` / `_append` / `_remove` / `_insert_after` / `_replace` | | ||
| 52 | +| props | `tui_node_set_str` / `_num` / `_bool`, and the matching reads | | ||
| 53 | +| reading back | `tui_node_tag`, `tui_node_child_at`, `tui_tree_dump`, `tui_screen_line` | | ||
| 54 | +| events | `tui_tree_poll_event` and the four accessors | | ||
| 55 | +| input by hand | `tui_feed_key` / `_click` / `_wheel`, `tui_focus` | | ||
| 56 | + | ||
| 57 | +## Keyboard | ||
| 58 | + | ||
| 59 | +Focus is a ring in paint order over the widgets that can take it — buttons, | ||
| 60 | +checkbuttons, entries, listboxes. Tab and Shift-Tab walk it, `:autofocus` | ||
| 61 | +claims it on the first frame, and a widget that is unmounted or turned | ||
| 62 | +insensitive gives it up rather than stranding the focus on nothing. | ||
| 63 | + | ||
| 64 | +| | | | ||
| 65 | +|---|---| | ||
| 66 | +| Enter, Space | activate the focused widget | | ||
| 67 | +| arrows, `j`/`k`, Page Up/Down, `g`/`G` | move a list's cursor | | ||
| 68 | +| Ctrl-A/E, Ctrl-W, Ctrl-U/K, Alt-B/F, Home/End | readline editing in an entry | | ||
| 69 | +| Esc | closes the topmost `:overlay` | | ||
| 70 | +| Ctrl-C, Ctrl-Q | quit | | ||
| 71 | + | ||
| 72 | +Anything nothing here wanted comes out as a `key` event on the focused node, | ||
| 73 | +named the way it is fed in — `"ctrl+u"`, `"page-down"`, `"f5"`. Bubbling it to a | ||
| 74 | +container's `:on-key` belongs to the caller: it holds the handlers. | ||
| 75 | + | ||
| 76 | +## Layout | ||
| 77 | + | ||
| 78 | +Every node answers two sizes — natural and minimum. A box hands out the natural | ||
| 79 | +ones when there is room, shrinks them proportionally toward the minimums when | ||
| 80 | +there is not, and gives the surplus to whoever set `:hexpand` / `:vexpand`. | ||
| 81 | +`:width-request` and `:height-request` are a floor on both, so asking for four | ||
| 82 | +rows gets four rows even when space is short. | ||
| 83 | + | ||
| 84 | +## Building | ||
| 85 | + | ||
| 86 | +```bash | ||
| 87 | +cargo build -p jolt-tui --release # target/release/libjolttui.so | ||
| 88 | +cargo test -p jolt-tui # the whole widget layer, headless | ||
| 89 | +cargo run -p jolt-tui --example showcase # every tag, in a terminal | ||
| 90 | +``` | ||
| 91 | + | ||
| 92 | +`--no-default-features` drops crossterm and with it `tui_open` — the tree, the | ||
| 93 | +layout, the painter and `tui_headless` all still work, which is what makes this | ||
| 94 | +crate buildable and checkable on a machine with no terminal crate and no TTY. | ||
| 95 | + | ||
| 96 | +## Limits | ||
| 97 | + | ||
| 98 | +* **One session per process, on one thread.** The session lives in thread-local | ||
| 99 | + storage, so a call from another thread is inert rather than unsound — the | ||
| 100 | + same rule libvidya keeps. | ||
| 101 | +* **One cell per character.** A wide CJK glyph or an emoji is measured as one | ||
| 102 | + column and will crowd its neighbour. Widths are a table lookup away; nothing | ||
| 103 | + in the design is in the way of adding one. | ||
| 104 | +* **No `:table`, `:paginator` or `:help` yet.** glimmer-tui has them; a table is | ||
| 105 | + a keyed listbox of rows here for now. Each is widget-layer work in | ||
| 106 | + `src/paint.rs` plus a measure in `src/layout.rs` and a tag. | ||
added
crates/jolt-tui/examples/showcase.rs +109 -0 | new file mode 100644 | ||
| @@ -0,0 +1,109 @@ | ||
| 1 | +//! Every tag this backend has, driven through the C ABI itself. | |
| 2 | +//! | |
| 3 | +//! cargo run -p jolt-tui --example showcase | |
| 4 | +//! | |
| 5 | +//! It calls `tui_*` exactly as jolt does — nodes by handle, props by name, | |
| 6 | +//! events polled off a queue — so it doubles as a check that the ABI is usable | |
| 7 | +//! from the outside and not only from the inside. | |
| 8 | +//! | |
| 9 | +//! Tab and Shift-Tab move, Enter and Space activate, j/k and the arrows move | |
| 10 | +//! the list, the wheel scrolls, Ctrl-C or Ctrl-Q quits. | |
| 11 | + | |
| 12 | +use std::ffi::{CStr, CString}; | |
| 13 | + | |
| 14 | +use jolttui::*; | |
| 15 | + | |
| 16 | +fn node(tag: &str) -> i32 { | |
| 17 | + let tag = CString::new(tag).unwrap(); | |
| 18 | + unsafe { tui_node_new(tag.as_ptr()) } | |
| 19 | +} | |
| 20 | + | |
| 21 | +fn set(id: i32, key: &str, value: &str) { | |
| 22 | + let (key, value) = (CString::new(key).unwrap(), CString::new(value).unwrap()); | |
| 23 | + unsafe { tui_node_set_str(id, key.as_ptr(), value.as_ptr()) } | |
| 24 | +} | |
| 25 | + | |
| 26 | +fn set_num(id: i32, key: &str, value: f64) { | |
| 27 | + let key = CString::new(key).unwrap(); | |
| 28 | + unsafe { tui_node_set_num(id, key.as_ptr(), value) } | |
| 29 | +} | |
| 30 | + | |
| 31 | +fn get(id: i32, key: &str) -> String { | |
| 32 | + let key = CString::new(key).unwrap(); | |
| 33 | + unsafe { CStr::from_ptr(tui_node_get_str(id, key.as_ptr())) } | |
| 34 | + .to_string_lossy() | |
| 35 | + .into_owned() | |
| 36 | +} | |
| 37 | + | |
| 38 | +fn child(parent: i32, tag: &str, props: &[(&str, &str)]) -> i32 { | |
| 39 | + let id = node(tag); | |
| 40 | + for (key, value) in props { | |
| 41 | + set(id, key, value); | |
| 42 | + } | |
| 43 | + tui_node_append(parent, id); | |
| 44 | + id | |
| 45 | +} | |
| 46 | + | |
| 47 | +fn main() { | |
| 48 | + if tui_open(1) == 0 { | |
| 49 | + eprintln!("showcase: this example needs a terminal"); | |
| 50 | + return; | |
| 51 | + } | |
| 52 | + let root = tui_tree_root(); | |
| 53 | + let page = child(root, "vbox", &[]); | |
| 54 | + set_num(page, "margin", 1.0); | |
| 55 | + set_num(page, "spacing", 1.0); | |
| 56 | + | |
| 57 | + child(page, "title", &[("label", "jolt-tui")]); | |
| 58 | + let status = child( | |
| 59 | + page, | |
| 60 | + "dim-label", | |
| 61 | + &[("label", "tab moves · enter activates · ctrl-c quits")], | |
| 62 | + ); | |
| 63 | + | |
| 64 | + let row = child(page, "hbox", &[("orientation", "horizontal")]); | |
| 65 | + set_num(row, "spacing", 2.0); | |
| 66 | + child(row, "button", &[("label", "count")]); | |
| 67 | + child( | |
| 68 | + row, | |
| 69 | + "button", | |
| 70 | + &[("label", "reset"), ("kind", "destructive")], | |
| 71 | + ); | |
| 72 | + child(row, "checkbutton", &[("label", "live")]); | |
| 73 | + | |
| 74 | + child(page, "entry", &[("placeholder", "type something")]); | |
| 75 | + let bar = child(page, "progress", &[("label", "")]); | |
| 76 | + let frame = child(page, "frame", &[("label", "Rows")]); | |
| 77 | + let list = child(frame, "listbox", &[]); | |
| 78 | + for name in ["alpha", "beta", "gamma", "delta"] { | |
| 79 | + child(list, "label", &[("label", name)]); | |
| 80 | + } | |
| 81 | + | |
| 82 | + let mut count = 0.0f64; | |
| 83 | + while tui_should_close() == 0 { | |
| 84 | + tui_tick(50); | |
| 85 | + while tui_tree_poll_event() == 1 { | |
| 86 | + let node = tui_tree_event_node(); | |
| 87 | + let name = unsafe { CStr::from_ptr(tui_tree_event_name()) } | |
| 88 | + .to_string_lossy() | |
| 89 | + .into_owned(); | |
| 90 | + let text = unsafe { CStr::from_ptr(tui_tree_event_text()) } | |
| 91 | + .to_string_lossy() | |
| 92 | + .into_owned(); | |
| 93 | + match name.as_str() { | |
| 94 | + "click" => { | |
| 95 | + let label = get(node, "label"); | |
| 96 | + count = if label == "reset" { 0.0 } else { count + 1.0 }; | |
| 97 | + set_num(bar, "value", (count / 10.0).min(1.0)); | |
| 98 | + set(status, "label", &format!("{label}: {count}")); | |
| 99 | + } | |
| 100 | + "select" | "activate" | "toggled" | "change" => { | |
| 101 | + set(status, "label", &format!("{name} {text}")); | |
| 102 | + } | |
| 103 | + _ => {} | |
| 104 | + } | |
| 105 | + } | |
| 106 | + tui_frame(); | |
| 107 | + } | |
| 108 | + tui_close(); | |
| 109 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,109 @@ | |||
| 1 | +//! Every tag this backend has, driven through the C ABI itself. | ||
| 2 | +//! | ||
| 3 | +//! cargo run -p jolt-tui --example showcase | ||
| 4 | +//! | ||
| 5 | +//! It calls `tui_*` exactly as jolt does — nodes by handle, props by name, | ||
| 6 | +//! events polled off a queue — so it doubles as a check that the ABI is usable | ||
| 7 | +//! from the outside and not only from the inside. | ||
| 8 | +//! | ||
| 9 | +//! Tab and Shift-Tab move, Enter and Space activate, j/k and the arrows move | ||
| 10 | +//! the list, the wheel scrolls, Ctrl-C or Ctrl-Q quits. | ||
| 11 | + | ||
| 12 | +use std::ffi::{CStr, CString}; | ||
| 13 | + | ||
| 14 | +use jolttui::*; | ||
| 15 | + | ||
| 16 | +fn node(tag: &str) -> i32 { | ||
| 17 | + let tag = CString::new(tag).unwrap(); | ||
| 18 | + unsafe { tui_node_new(tag.as_ptr()) } | ||
| 19 | +} | ||
| 20 | + | ||
| 21 | +fn set(id: i32, key: &str, value: &str) { | ||
| 22 | + let (key, value) = (CString::new(key).unwrap(), CString::new(value).unwrap()); | ||
| 23 | + unsafe { tui_node_set_str(id, key.as_ptr(), value.as_ptr()) } | ||
| 24 | +} | ||
| 25 | + | ||
| 26 | +fn set_num(id: i32, key: &str, value: f64) { | ||
| 27 | + let key = CString::new(key).unwrap(); | ||
| 28 | + unsafe { tui_node_set_num(id, key.as_ptr(), value) } | ||
| 29 | +} | ||
| 30 | + | ||
| 31 | +fn get(id: i32, key: &str) -> String { | ||
| 32 | + let key = CString::new(key).unwrap(); | ||
| 33 | + unsafe { CStr::from_ptr(tui_node_get_str(id, key.as_ptr())) } | ||
| 34 | + .to_string_lossy() | ||
| 35 | + .into_owned() | ||
| 36 | +} | ||
| 37 | + | ||
| 38 | +fn child(parent: i32, tag: &str, props: &[(&str, &str)]) -> i32 { | ||
| 39 | + let id = node(tag); | ||
| 40 | + for (key, value) in props { | ||
| 41 | + set(id, key, value); | ||
| 42 | + } | ||
| 43 | + tui_node_append(parent, id); | ||
| 44 | + id | ||
| 45 | +} | ||
| 46 | + | ||
| 47 | +fn main() { | ||
| 48 | + if tui_open(1) == 0 { | ||
| 49 | + eprintln!("showcase: this example needs a terminal"); | ||
| 50 | + return; | ||
| 51 | + } | ||
| 52 | + let root = tui_tree_root(); | ||
| 53 | + let page = child(root, "vbox", &[]); | ||
| 54 | + set_num(page, "margin", 1.0); | ||
| 55 | + set_num(page, "spacing", 1.0); | ||
| 56 | + | ||
| 57 | + child(page, "title", &[("label", "jolt-tui")]); | ||
| 58 | + let status = child( | ||
| 59 | + page, | ||
| 60 | + "dim-label", | ||
| 61 | + &[("label", "tab moves · enter activates · ctrl-c quits")], | ||
| 62 | + ); | ||
| 63 | + | ||
| 64 | + let row = child(page, "hbox", &[("orientation", "horizontal")]); | ||
| 65 | + set_num(row, "spacing", 2.0); | ||
| 66 | + child(row, "button", &[("label", "count")]); | ||
| 67 | + child( | ||
| 68 | + row, | ||
| 69 | + "button", | ||
| 70 | + &[("label", "reset"), ("kind", "destructive")], | ||
| 71 | + ); | ||
| 72 | + child(row, "checkbutton", &[("label", "live")]); | ||
| 73 | + | ||
| 74 | + child(page, "entry", &[("placeholder", "type something")]); | ||
| 75 | + let bar = child(page, "progress", &[("label", "")]); | ||
| 76 | + let frame = child(page, "frame", &[("label", "Rows")]); | ||
| 77 | + let list = child(frame, "listbox", &[]); | ||
| 78 | + for name in ["alpha", "beta", "gamma", "delta"] { | ||
| 79 | + child(list, "label", &[("label", name)]); | ||
| 80 | + } | ||
| 81 | + | ||
| 82 | + let mut count = 0.0f64; | ||
| 83 | + while tui_should_close() == 0 { | ||
| 84 | + tui_tick(50); | ||
| 85 | + while tui_tree_poll_event() == 1 { | ||
| 86 | + let node = tui_tree_event_node(); | ||
| 87 | + let name = unsafe { CStr::from_ptr(tui_tree_event_name()) } | ||
| 88 | + .to_string_lossy() | ||
| 89 | + .into_owned(); | ||
| 90 | + let text = unsafe { CStr::from_ptr(tui_tree_event_text()) } | ||
| 91 | + .to_string_lossy() | ||
| 92 | + .into_owned(); | ||
| 93 | + match name.as_str() { | ||
| 94 | + "click" => { | ||
| 95 | + let label = get(node, "label"); | ||
| 96 | + count = if label == "reset" { 0.0 } else { count + 1.0 }; | ||
| 97 | + set_num(bar, "value", (count / 10.0).min(1.0)); | ||
| 98 | + set(status, "label", &format!("{label}: {count}")); | ||
| 99 | + } | ||
| 100 | + "select" | "activate" | "toggled" | "change" => { | ||
| 101 | + set(status, "label", &format!("{name} {text}")); | ||
| 102 | + } | ||
| 103 | + _ => {} | ||
| 104 | + } | ||
| 105 | + } | ||
| 106 | + tui_frame(); | ||
| 107 | + } | ||
| 108 | + tui_close(); | ||
| 109 | +} | ||
added
crates/jolt-tui/include/jolttui.h +219 -0 | new file mode 100644 | ||
| @@ -0,0 +1,219 @@ | ||
| 1 | +/* | |
| 2 | + * jolt-tui — glimmer's terminal backend, behind the retained-tree ABI. | |
| 3 | + * | |
| 4 | + * The tree half of `vidya_tree.h` with a terminal under it instead of a GPU | |
| 5 | + * window: nodes are integer handles, mutated by the calls below, and nothing is | |
| 6 | + * drawn until `tui_frame`, which lays the whole tree out and paints it at once. | |
| 7 | + * Interactions come back as a queue the caller drains and routes to its own | |
| 8 | + * handlers — a callback cannot cross this boundary, so identity does instead. | |
| 9 | + * | |
| 10 | + * It is deliberately the same shape as libvidya's, because it is the same | |
| 11 | + * glimmer backend on the other side: one reconciler, and a choice of shared | |
| 12 | + * object. The differences are the ones a terminal actually forces — a focus | |
| 13 | + * ring and keys instead of a pointer and hit testing, cells instead of points, | |
| 14 | + * and colours that are the terminal's rather than a theme's. | |
| 15 | + * | |
| 16 | + * Every call stays on the thread that opened the session, which is where the | |
| 17 | + * state lives; a call from another thread is inert rather than unsound. | |
| 18 | + * | |
| 19 | + * Tags are hiccup names without the colon: | |
| 20 | + * | |
| 21 | + * containers window box hbox vbox frame scroll overlay listbox | |
| 22 | + * widgets label title title-2 dim-label button checkbutton entry | |
| 23 | + * separator spacer progress spinner | |
| 24 | + * | |
| 25 | + * An unrecognized tag is kept and paints as a vertical box, so a caller ahead | |
| 26 | + * of this backend still sees its children. | |
| 27 | + * | |
| 28 | + * Props are string-keyed, in the same names: | |
| 29 | + * | |
| 30 | + * every node sensitive (bool), margin, padding, width-request, | |
| 31 | + * height-request, halign / valign | |
| 32 | + * ("fill"|"start"|"center"|"end"), hexpand / vexpand (bool), | |
| 33 | + * color, bg, bold, dim, underline, reverse, blink, italic | |
| 34 | + * box orientation ("horizontal"|"vertical"), spacing | |
| 35 | + * frame label (drawn into the top border) | |
| 36 | + * scroll offset (rows, clamped to the content and written back) | |
| 37 | + * overlay label; floats centred over everything else | |
| 38 | + * text widgets label, or text | |
| 39 | + * button label, kind ("default"|"primary"|"destructive") | |
| 40 | + * checkbutton label, active (bool) | |
| 41 | + * entry text, placeholder, rows, autofocus (bool) | |
| 42 | + * listbox selected (row index; -1 for no cursor). Its children are | |
| 43 | + * the rows, one node each. | |
| 44 | + * spacer size | |
| 45 | + * progress value (0..1), label | |
| 46 | + * | |
| 47 | + * A colour is a name (`red`, `bright-blue`, `default`), an index into the | |
| 48 | + * xterm 256-colour palette (`"33"`), a hex triple (`#ff6432`, `#f64`), or | |
| 49 | + * `"r,g,b"`. Colour and attributes are inherited by a node's whole subtree. | |
| 50 | + */ | |
| 51 | +#ifndef JOLTTUI_H | |
| 52 | +#define JOLTTUI_H | |
| 53 | + | |
| 54 | +#if defined(_WIN32) | |
| 55 | +# if defined(JOLTTUI_BUILD) | |
| 56 | +# define JOLTTUI_API __declspec(dllexport) | |
| 57 | +# else | |
| 58 | +# define JOLTTUI_API __declspec(dllimport) | |
| 59 | +# endif | |
| 60 | +#elif defined(__GNUC__) | |
| 61 | +# define JOLTTUI_API __attribute__((visibility("default"))) | |
| 62 | +#else | |
| 63 | +# define JOLTTUI_API | |
| 64 | +#endif | |
| 65 | + | |
| 66 | +#ifdef __cplusplus | |
| 67 | +extern "C" { | |
| 68 | +#endif | |
| 69 | + | |
| 70 | +/* The session | |
| 71 | + * | |
| 72 | + * `tui_open` takes the terminal: raw mode, the alternate screen, no cursor | |
| 73 | + * unless a focused entry asks for one, and mouse reporting when `mouse` is | |
| 74 | + * non-zero. 1 on success, 0 if a session is already open or the terminal | |
| 75 | + * refused. `tui_close` gives all of it back and is safe to call twice. | |
| 76 | + * | |
| 77 | + * `tui_headless` opens a session of a fixed size with no terminal at all. The | |
| 78 | + * whole widget layer works there — layout, painting, focus, keys fed by hand — | |
| 79 | + * and `tui_screen_line` reads the result back, which is what a test suite and | |
| 80 | + * CI use. It is the same code path a real session paints through. | |
| 81 | + */ | |
| 82 | +JOLTTUI_API int tui_open(int mouse); | |
| 83 | +JOLTTUI_API int tui_headless(int width, int height); | |
| 84 | +JOLTTUI_API void tui_close(void); | |
| 85 | + | |
| 86 | +/* 1 once Ctrl-C, Ctrl-Q or `tui_quit` has asked the loop to stop — and before | |
| 87 | + * a session is open, so a loop written around it cannot spin forever. */ | |
| 88 | +JOLTTUI_API int tui_should_close(void); | |
| 89 | +JOLTTUI_API void tui_quit(void); | |
| 90 | + | |
| 91 | +/* | |
| 92 | + * One turn of the loop. | |
| 93 | + * | |
| 94 | + * `tui_tick` waits up to `timeout_ms` for input, handles everything that | |
| 95 | + * arrived — keys, clicks, the wheel, a resize — and answers how many things it | |
| 96 | + * handled, so a caller can skip a repaint when nothing did. `tui_frame` lays | |
| 97 | + * the tree out, paints it, and sends only the cells that changed. | |
| 98 | + * | |
| 99 | + * A headless session ticks to 0 and is fed with the calls below instead. | |
| 100 | + */ | |
| 101 | +JOLTTUI_API int tui_tick(int timeout_ms); | |
| 102 | +JOLTTUI_API void tui_frame(void); | |
| 103 | + | |
| 104 | +/* The screen, in cells. 0 before a session is open. */ | |
| 105 | +JOLTTUI_API int tui_screen_width(void); | |
| 106 | +JOLTTUI_API int tui_screen_height(void); | |
| 107 | + | |
| 108 | +/* | |
| 109 | + * One painted row as text, trailing blanks trimmed — what a test asserts on | |
| 110 | + * and what a bug report pastes. The returned pointer belongs to the library | |
| 111 | + * and is valid only until the next string-returning read on this thread. | |
| 112 | + */ | |
| 113 | +JOLTTUI_API const char *tui_screen_line(int y); | |
| 114 | + | |
| 115 | +/* | |
| 116 | + * Input by hand. | |
| 117 | + * | |
| 118 | + * `tui_feed_key` takes a key by name, as the terminal's own keys are named: | |
| 119 | + * "a", "space", "enter", "tab", "shift+tab", "esc", "up", "page-down", | |
| 120 | + * "ctrl+u", "alt+f", "f5". It answers 1 when the backend acted on it and 0 | |
| 121 | + * when it went out as a `key` event instead. `tui_feed_click` and | |
| 122 | + * `tui_feed_wheel` do the same for the mouse; the wheel's `by` is in rows and | |
| 123 | + * negative is up. | |
| 124 | + * | |
| 125 | + * These are the same entry points a real terminal's input arrives through, so | |
| 126 | + * a test drives the UI exactly as a person does. | |
| 127 | + */ | |
| 128 | +JOLTTUI_API int tui_feed_key(const char *name); | |
| 129 | +JOLTTUI_API int tui_feed_click(int x, int y); | |
| 130 | +JOLTTUI_API int tui_feed_wheel(int x, int y, int by); | |
| 131 | + | |
| 132 | +/* The focused node, 0 for none. Focus follows the ring — Tab and Shift-Tab | |
| 133 | + * walk it in paint order, `autofocus` claims it on the first frame, and a | |
| 134 | + * widget that is unmounted or turned insensitive gives it up. */ | |
| 135 | +JOLTTUI_API int tui_focus(void); | |
| 136 | + | |
| 137 | +/* The window node, created with the session. Mount everything under it. */ | |
| 138 | +JOLTTUI_API int tui_tree_root(void); | |
| 139 | + | |
| 140 | +JOLTTUI_API int tui_node_new(const char *tag); | |
| 141 | +JOLTTUI_API void tui_node_free(int node); | |
| 142 | +JOLTTUI_API int tui_node_exists(int node); | |
| 143 | + | |
| 144 | +JOLTTUI_API void tui_node_set_str(int node, const char *key, const char *value); | |
| 145 | +JOLTTUI_API void tui_node_set_num(int node, const char *key, double value); | |
| 146 | +JOLTTUI_API void tui_node_set_bool(int node, const char *key, int value); | |
| 147 | +JOLTTUI_API void tui_node_clear_props(int node); | |
| 148 | + | |
| 149 | +/* | |
| 150 | + * Reads answer the empty string / 0 for a prop that is unset or of another | |
| 151 | + * type. The returned pointer belongs to the library and is valid only until | |
| 152 | + * the next string-returning call of its family on this thread — props, tags | |
| 153 | + * and screen lines are one family, dumps another, and an event's name and its | |
| 154 | + * text are each their own, so an event can be read whole. | |
| 155 | + */ | |
| 156 | +JOLTTUI_API const char *tui_node_get_str(int node, const char *key); | |
| 157 | +JOLTTUI_API double tui_node_get_num(int node, const char *key); | |
| 158 | +JOLTTUI_API int tui_node_get_bool(int node, const char *key); | |
| 159 | + | |
| 160 | +/* Reading the structure back. `tui_node_tag` answers the canonical tag name | |
| 161 | + * ("box" for both hbox and vbox); `tui_node_child_at` answers 0 past the end. */ | |
| 162 | +JOLTTUI_API const char *tui_node_tag(int node); | |
| 163 | +JOLTTUI_API int tui_node_parent(int node); | |
| 164 | +JOLTTUI_API int tui_node_child_count(int node); | |
| 165 | +JOLTTUI_API int tui_node_child_at(int node, int index); | |
| 166 | + | |
| 167 | +/* | |
| 168 | + * The subtree at `node` as pretty-printed hiccup — `[:tag {props} children...]`, | |
| 169 | + * one node to a line. `node` 0 means the root, so `tui_tree_dump(0)` is the | |
| 170 | + * whole window. It answers what the tree *is*, read back from the arena, | |
| 171 | + * rather than what a caller meant to build. Props are sorted, so two dumps of | |
| 172 | + * the same tree compare as text; handlers are absent because they never | |
| 173 | + * crossed this boundary. | |
| 174 | + */ | |
| 175 | +JOLTTUI_API const char *tui_tree_dump(int node); | |
| 176 | + | |
| 177 | +JOLTTUI_API int tui_node_append(int parent, int child); | |
| 178 | +/* Unparents AND frees `child` with everything under it. */ | |
| 179 | +JOLTTUI_API void tui_node_remove(int parent, int child); | |
| 180 | +/* Moves `child` after `sibling`; `sibling` 0 means the first position. */ | |
| 181 | +JOLTTUI_API int tui_node_insert_after(int parent, int child, int sibling); | |
| 182 | +/* Puts `new_child` where `old_child` was, and frees `old_child`. */ | |
| 183 | +JOLTTUI_API int tui_node_replace(int parent, int old_child, int new_child); | |
| 184 | + | |
| 185 | +/* | |
| 186 | + * Drain interactions. `tui_tree_poll_event` dequeues one and answers 1 while | |
| 187 | + * there was one; the accessors describe whichever was dequeued last. | |
| 188 | + * | |
| 189 | + * Event names are glimmer's handler props without the `on-`: | |
| 190 | + * | |
| 191 | + * click a button was pressed no payload | |
| 192 | + * toggled a checkbutton changed num is the new state | |
| 193 | + * change an entry's text changed text is the new text | |
| 194 | + * activate Enter in an entry or a list text is the text or the row | |
| 195 | + * select a list's cursor moved text is the row, num its index | |
| 196 | + * scroll a viewport moved num is the new offset | |
| 197 | + * close Esc in an overlay no payload | |
| 198 | + * key a key nothing here wanted text is the key's name | |
| 199 | + * | |
| 200 | + * A widget does not own its value: `toggled`, `change`, `select` and `scroll` | |
| 201 | + * write the new state back into the node's props as well, so a caller that | |
| 202 | + * ignores the event still sees a working control, and the next prop write is | |
| 203 | + * what settles it. | |
| 204 | + * | |
| 205 | + * A `key` event is reported on the focused node, or on the window when nothing | |
| 206 | + * has focus. Bubbling it to a container's `:on-key` is the caller's to do: it | |
| 207 | + * holds the handlers and knows the tree. | |
| 208 | + */ | |
| 209 | +JOLTTUI_API int tui_tree_poll_event(void); | |
| 210 | +JOLTTUI_API int tui_tree_event_node(void); | |
| 211 | +JOLTTUI_API const char *tui_tree_event_name(void); | |
| 212 | +JOLTTUI_API const char *tui_tree_event_text(void); | |
| 213 | +JOLTTUI_API double tui_tree_event_num(void); | |
| 214 | + | |
| 215 | +#ifdef __cplusplus | |
| 216 | +} | |
| 217 | +#endif | |
| 218 | + | |
| 219 | +#endif /* JOLTTUI_H */ | |
| new file mode 100644 | |||
| @@ -0,0 +1,219 @@ | |||
| 1 | +/* | ||
| 2 | + * jolt-tui — glimmer's terminal backend, behind the retained-tree ABI. | ||
| 3 | + * | ||
| 4 | + * The tree half of `vidya_tree.h` with a terminal under it instead of a GPU | ||
| 5 | + * window: nodes are integer handles, mutated by the calls below, and nothing is | ||
| 6 | + * drawn until `tui_frame`, which lays the whole tree out and paints it at once. | ||
| 7 | + * Interactions come back as a queue the caller drains and routes to its own | ||
| 8 | + * handlers — a callback cannot cross this boundary, so identity does instead. | ||
| 9 | + * | ||
| 10 | + * It is deliberately the same shape as libvidya's, because it is the same | ||
| 11 | + * glimmer backend on the other side: one reconciler, and a choice of shared | ||
| 12 | + * object. The differences are the ones a terminal actually forces — a focus | ||
| 13 | + * ring and keys instead of a pointer and hit testing, cells instead of points, | ||
| 14 | + * and colours that are the terminal's rather than a theme's. | ||
| 15 | + * | ||
| 16 | + * Every call stays on the thread that opened the session, which is where the | ||
| 17 | + * state lives; a call from another thread is inert rather than unsound. | ||
| 18 | + * | ||
| 19 | + * Tags are hiccup names without the colon: | ||
| 20 | + * | ||
| 21 | + * containers window box hbox vbox frame scroll overlay listbox | ||
| 22 | + * widgets label title title-2 dim-label button checkbutton entry | ||
| 23 | + * separator spacer progress spinner | ||
| 24 | + * | ||
| 25 | + * An unrecognized tag is kept and paints as a vertical box, so a caller ahead | ||
| 26 | + * of this backend still sees its children. | ||
| 27 | + * | ||
| 28 | + * Props are string-keyed, in the same names: | ||
| 29 | + * | ||
| 30 | + * every node sensitive (bool), margin, padding, width-request, | ||
| 31 | + * height-request, halign / valign | ||
| 32 | + * ("fill"|"start"|"center"|"end"), hexpand / vexpand (bool), | ||
| 33 | + * color, bg, bold, dim, underline, reverse, blink, italic | ||
| 34 | + * box orientation ("horizontal"|"vertical"), spacing | ||
| 35 | + * frame label (drawn into the top border) | ||
| 36 | + * scroll offset (rows, clamped to the content and written back) | ||
| 37 | + * overlay label; floats centred over everything else | ||
| 38 | + * text widgets label, or text | ||
| 39 | + * button label, kind ("default"|"primary"|"destructive") | ||
| 40 | + * checkbutton label, active (bool) | ||
| 41 | + * entry text, placeholder, rows, autofocus (bool) | ||
| 42 | + * listbox selected (row index; -1 for no cursor). Its children are | ||
| 43 | + * the rows, one node each. | ||
| 44 | + * spacer size | ||
| 45 | + * progress value (0..1), label | ||
| 46 | + * | ||
| 47 | + * A colour is a name (`red`, `bright-blue`, `default`), an index into the | ||
| 48 | + * xterm 256-colour palette (`"33"`), a hex triple (`#ff6432`, `#f64`), or | ||
| 49 | + * `"r,g,b"`. Colour and attributes are inherited by a node's whole subtree. | ||
| 50 | + */ | ||
| 51 | +#ifndef JOLTTUI_H | ||
| 52 | +#define JOLTTUI_H | ||
| 53 | + | ||
| 54 | +#if defined(_WIN32) | ||
| 55 | +# if defined(JOLTTUI_BUILD) | ||
| 56 | +# define JOLTTUI_API __declspec(dllexport) | ||
| 57 | +# else | ||
| 58 | +# define JOLTTUI_API __declspec(dllimport) | ||
| 59 | +# endif | ||
| 60 | +#elif defined(__GNUC__) | ||
| 61 | +# define JOLTTUI_API __attribute__((visibility("default"))) | ||
| 62 | +#else | ||
| 63 | +# define JOLTTUI_API | ||
| 64 | +#endif | ||
| 65 | + | ||
| 66 | +#ifdef __cplusplus | ||
| 67 | +extern "C" { | ||
| 68 | +#endif | ||
| 69 | + | ||
| 70 | +/* The session | ||
| 71 | + * | ||
| 72 | + * `tui_open` takes the terminal: raw mode, the alternate screen, no cursor | ||
| 73 | + * unless a focused entry asks for one, and mouse reporting when `mouse` is | ||
| 74 | + * non-zero. 1 on success, 0 if a session is already open or the terminal | ||
| 75 | + * refused. `tui_close` gives all of it back and is safe to call twice. | ||
| 76 | + * | ||
| 77 | + * `tui_headless` opens a session of a fixed size with no terminal at all. The | ||
| 78 | + * whole widget layer works there — layout, painting, focus, keys fed by hand — | ||
| 79 | + * and `tui_screen_line` reads the result back, which is what a test suite and | ||
| 80 | + * CI use. It is the same code path a real session paints through. | ||
| 81 | + */ | ||
| 82 | +JOLTTUI_API int tui_open(int mouse); | ||
| 83 | +JOLTTUI_API int tui_headless(int width, int height); | ||
| 84 | +JOLTTUI_API void tui_close(void); | ||
| 85 | + | ||
| 86 | +/* 1 once Ctrl-C, Ctrl-Q or `tui_quit` has asked the loop to stop — and before | ||
| 87 | + * a session is open, so a loop written around it cannot spin forever. */ | ||
| 88 | +JOLTTUI_API int tui_should_close(void); | ||
| 89 | +JOLTTUI_API void tui_quit(void); | ||
| 90 | + | ||
| 91 | +/* | ||
| 92 | + * One turn of the loop. | ||
| 93 | + * | ||
| 94 | + * `tui_tick` waits up to `timeout_ms` for input, handles everything that | ||
| 95 | + * arrived — keys, clicks, the wheel, a resize — and answers how many things it | ||
| 96 | + * handled, so a caller can skip a repaint when nothing did. `tui_frame` lays | ||
| 97 | + * the tree out, paints it, and sends only the cells that changed. | ||
| 98 | + * | ||
| 99 | + * A headless session ticks to 0 and is fed with the calls below instead. | ||
| 100 | + */ | ||
| 101 | +JOLTTUI_API int tui_tick(int timeout_ms); | ||
| 102 | +JOLTTUI_API void tui_frame(void); | ||
| 103 | + | ||
| 104 | +/* The screen, in cells. 0 before a session is open. */ | ||
| 105 | +JOLTTUI_API int tui_screen_width(void); | ||
| 106 | +JOLTTUI_API int tui_screen_height(void); | ||
| 107 | + | ||
| 108 | +/* | ||
| 109 | + * One painted row as text, trailing blanks trimmed — what a test asserts on | ||
| 110 | + * and what a bug report pastes. The returned pointer belongs to the library | ||
| 111 | + * and is valid only until the next string-returning read on this thread. | ||
| 112 | + */ | ||
| 113 | +JOLTTUI_API const char *tui_screen_line(int y); | ||
| 114 | + | ||
| 115 | +/* | ||
| 116 | + * Input by hand. | ||
| 117 | + * | ||
| 118 | + * `tui_feed_key` takes a key by name, as the terminal's own keys are named: | ||
| 119 | + * "a", "space", "enter", "tab", "shift+tab", "esc", "up", "page-down", | ||
| 120 | + * "ctrl+u", "alt+f", "f5". It answers 1 when the backend acted on it and 0 | ||
| 121 | + * when it went out as a `key` event instead. `tui_feed_click` and | ||
| 122 | + * `tui_feed_wheel` do the same for the mouse; the wheel's `by` is in rows and | ||
| 123 | + * negative is up. | ||
| 124 | + * | ||
| 125 | + * These are the same entry points a real terminal's input arrives through, so | ||
| 126 | + * a test drives the UI exactly as a person does. | ||
| 127 | + */ | ||
| 128 | +JOLTTUI_API int tui_feed_key(const char *name); | ||
| 129 | +JOLTTUI_API int tui_feed_click(int x, int y); | ||
| 130 | +JOLTTUI_API int tui_feed_wheel(int x, int y, int by); | ||
| 131 | + | ||
| 132 | +/* The focused node, 0 for none. Focus follows the ring — Tab and Shift-Tab | ||
| 133 | + * walk it in paint order, `autofocus` claims it on the first frame, and a | ||
| 134 | + * widget that is unmounted or turned insensitive gives it up. */ | ||
| 135 | +JOLTTUI_API int tui_focus(void); | ||
| 136 | + | ||
| 137 | +/* The window node, created with the session. Mount everything under it. */ | ||
| 138 | +JOLTTUI_API int tui_tree_root(void); | ||
| 139 | + | ||
| 140 | +JOLTTUI_API int tui_node_new(const char *tag); | ||
| 141 | +JOLTTUI_API void tui_node_free(int node); | ||
| 142 | +JOLTTUI_API int tui_node_exists(int node); | ||
| 143 | + | ||
| 144 | +JOLTTUI_API void tui_node_set_str(int node, const char *key, const char *value); | ||
| 145 | +JOLTTUI_API void tui_node_set_num(int node, const char *key, double value); | ||
| 146 | +JOLTTUI_API void tui_node_set_bool(int node, const char *key, int value); | ||
| 147 | +JOLTTUI_API void tui_node_clear_props(int node); | ||
| 148 | + | ||
| 149 | +/* | ||
| 150 | + * Reads answer the empty string / 0 for a prop that is unset or of another | ||
| 151 | + * type. The returned pointer belongs to the library and is valid only until | ||
| 152 | + * the next string-returning call of its family on this thread — props, tags | ||
| 153 | + * and screen lines are one family, dumps another, and an event's name and its | ||
| 154 | + * text are each their own, so an event can be read whole. | ||
| 155 | + */ | ||
| 156 | +JOLTTUI_API const char *tui_node_get_str(int node, const char *key); | ||
| 157 | +JOLTTUI_API double tui_node_get_num(int node, const char *key); | ||
| 158 | +JOLTTUI_API int tui_node_get_bool(int node, const char *key); | ||
| 159 | + | ||
| 160 | +/* Reading the structure back. `tui_node_tag` answers the canonical tag name | ||
| 161 | + * ("box" for both hbox and vbox); `tui_node_child_at` answers 0 past the end. */ | ||
| 162 | +JOLTTUI_API const char *tui_node_tag(int node); | ||
| 163 | +JOLTTUI_API int tui_node_parent(int node); | ||
| 164 | +JOLTTUI_API int tui_node_child_count(int node); | ||
| 165 | +JOLTTUI_API int tui_node_child_at(int node, int index); | ||
| 166 | + | ||
| 167 | +/* | ||
| 168 | + * The subtree at `node` as pretty-printed hiccup — `[:tag {props} children...]`, | ||
| 169 | + * one node to a line. `node` 0 means the root, so `tui_tree_dump(0)` is the | ||
| 170 | + * whole window. It answers what the tree *is*, read back from the arena, | ||
| 171 | + * rather than what a caller meant to build. Props are sorted, so two dumps of | ||
| 172 | + * the same tree compare as text; handlers are absent because they never | ||
| 173 | + * crossed this boundary. | ||
| 174 | + */ | ||
| 175 | +JOLTTUI_API const char *tui_tree_dump(int node); | ||
| 176 | + | ||
| 177 | +JOLTTUI_API int tui_node_append(int parent, int child); | ||
| 178 | +/* Unparents AND frees `child` with everything under it. */ | ||
| 179 | +JOLTTUI_API void tui_node_remove(int parent, int child); | ||
| 180 | +/* Moves `child` after `sibling`; `sibling` 0 means the first position. */ | ||
| 181 | +JOLTTUI_API int tui_node_insert_after(int parent, int child, int sibling); | ||
| 182 | +/* Puts `new_child` where `old_child` was, and frees `old_child`. */ | ||
| 183 | +JOLTTUI_API int tui_node_replace(int parent, int old_child, int new_child); | ||
| 184 | + | ||
| 185 | +/* | ||
| 186 | + * Drain interactions. `tui_tree_poll_event` dequeues one and answers 1 while | ||
| 187 | + * there was one; the accessors describe whichever was dequeued last. | ||
| 188 | + * | ||
| 189 | + * Event names are glimmer's handler props without the `on-`: | ||
| 190 | + * | ||
| 191 | + * click a button was pressed no payload | ||
| 192 | + * toggled a checkbutton changed num is the new state | ||
| 193 | + * change an entry's text changed text is the new text | ||
| 194 | + * activate Enter in an entry or a list text is the text or the row | ||
| 195 | + * select a list's cursor moved text is the row, num its index | ||
| 196 | + * scroll a viewport moved num is the new offset | ||
| 197 | + * close Esc in an overlay no payload | ||
| 198 | + * key a key nothing here wanted text is the key's name | ||
| 199 | + * | ||
| 200 | + * A widget does not own its value: `toggled`, `change`, `select` and `scroll` | ||
| 201 | + * write the new state back into the node's props as well, so a caller that | ||
| 202 | + * ignores the event still sees a working control, and the next prop write is | ||
| 203 | + * what settles it. | ||
| 204 | + * | ||
| 205 | + * A `key` event is reported on the focused node, or on the window when nothing | ||
| 206 | + * has focus. Bubbling it to a container's `:on-key` is the caller's to do: it | ||
| 207 | + * holds the handlers and knows the tree. | ||
| 208 | + */ | ||
| 209 | +JOLTTUI_API int tui_tree_poll_event(void); | ||
| 210 | +JOLTTUI_API int tui_tree_event_node(void); | ||
| 211 | +JOLTTUI_API const char *tui_tree_event_name(void); | ||
| 212 | +JOLTTUI_API const char *tui_tree_event_text(void); | ||
| 213 | +JOLTTUI_API double tui_tree_event_num(void); | ||
| 214 | + | ||
| 215 | +#ifdef __cplusplus | ||
| 216 | +} | ||
| 217 | +#endif | ||
| 218 | + | ||
| 219 | +#endif /* JOLTTUI_H */ | ||
added
crates/jolt-tui/src/keys.rs +105 -0 | new file mode 100644 | ||
| @@ -0,0 +1,105 @@ | ||
| 1 | +//! Key names, and the two word motions the entry needs. | |
| 2 | +//! | |
| 3 | +//! A key crosses this ABI as a string — `"ctrl+u"`, `"page-down"`, `"a"` — | |
| 4 | +//! because that is the only shape jolt can match on, and because a name is | |
| 5 | +//! something a test can type. The same names glimmer-tui's `k/match?` takes. | |
| 6 | + | |
| 7 | +/// Where the word before `at` starts. | |
| 8 | +pub fn word_left(text: &[char], at: usize) -> usize { | |
| 9 | + let mut i = at.min(text.len()); | |
| 10 | + while i > 0 && text[i - 1].is_whitespace() { | |
| 11 | + i -= 1; | |
| 12 | + } | |
| 13 | + while i > 0 && !text[i - 1].is_whitespace() { | |
| 14 | + i -= 1; | |
| 15 | + } | |
| 16 | + i | |
| 17 | +} | |
| 18 | + | |
| 19 | +/// Where the word after `at` ends. | |
| 20 | +pub fn word_right(text: &[char], at: usize) -> usize { | |
| 21 | + let mut i = at.min(text.len()); | |
| 22 | + while i < text.len() && text[i].is_whitespace() { | |
| 23 | + i += 1; | |
| 24 | + } | |
| 25 | + while i < text.len() && !text[i].is_whitespace() { | |
| 26 | + i += 1; | |
| 27 | + } | |
| 28 | + i | |
| 29 | +} | |
| 30 | + | |
| 31 | +#[cfg(feature = "terminal")] | |
| 32 | +mod terminal { | |
| 33 | + use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; | |
| 34 | + | |
| 35 | + /// The name for one key event, or `None` for a key with no name — a bare | |
| 36 | + /// modifier press, or a media key nothing here would do anything with. | |
| 37 | + pub fn name(event: KeyEvent) -> Option<String> { | |
| 38 | + let mods = event.modifiers; | |
| 39 | + let base = match event.code { | |
| 40 | + KeyCode::Char(' ') => "space".to_owned(), | |
| 41 | + KeyCode::Char(c) => { | |
| 42 | + // Shift is already in the character the terminal sent; saying | |
| 43 | + // it twice would make `A` into `shift+a`, which nothing wants | |
| 44 | + // to match on. | |
| 45 | + let mut name = c.to_string(); | |
| 46 | + if mods.contains(KeyModifiers::CONTROL) || mods.contains(KeyModifiers::ALT) { | |
| 47 | + name = c.to_lowercase().to_string(); | |
| 48 | + } | |
| 49 | + name | |
| 50 | + } | |
| 51 | + KeyCode::Enter => "enter".into(), | |
| 52 | + KeyCode::Tab => "tab".into(), | |
| 53 | + KeyCode::BackTab => return Some("shift+tab".into()), | |
| 54 | + KeyCode::Backspace => "backspace".into(), | |
| 55 | + KeyCode::Delete => "delete".into(), | |
| 56 | + KeyCode::Insert => "insert".into(), | |
| 57 | + KeyCode::Esc => "esc".into(), | |
| 58 | + KeyCode::Up => "up".into(), | |
| 59 | + KeyCode::Down => "down".into(), | |
| 60 | + KeyCode::Left => "left".into(), | |
| 61 | + KeyCode::Right => "right".into(), | |
| 62 | + KeyCode::Home => "home".into(), | |
| 63 | + KeyCode::End => "end".into(), | |
| 64 | + KeyCode::PageUp => "page-up".into(), | |
| 65 | + KeyCode::PageDown => "page-down".into(), | |
| 66 | + KeyCode::F(n) => format!("f{n}"), | |
| 67 | + _ => return None, | |
| 68 | + }; | |
| 69 | + let mut out = String::new(); | |
| 70 | + if mods.contains(KeyModifiers::CONTROL) { | |
| 71 | + out.push_str("ctrl+"); | |
| 72 | + } | |
| 73 | + if mods.contains(KeyModifiers::ALT) { | |
| 74 | + out.push_str("alt+"); | |
| 75 | + } | |
| 76 | + if mods.contains(KeyModifiers::SHIFT) && !matches!(event.code, KeyCode::Char(_)) { | |
| 77 | + out.push_str("shift+"); | |
| 78 | + } | |
| 79 | + out.push_str(&base); | |
| 80 | + Some(out) | |
| 81 | + } | |
| 82 | +} | |
| 83 | + | |
| 84 | +#[cfg(feature = "terminal")] | |
| 85 | +pub use terminal::name; | |
| 86 | + | |
| 87 | +#[cfg(test)] | |
| 88 | +mod tests { | |
| 89 | + use super::*; | |
| 90 | + | |
| 91 | + fn chars(text: &str) -> Vec<char> { | |
| 92 | + text.chars().collect() | |
| 93 | + } | |
| 94 | + | |
| 95 | + #[test] | |
| 96 | + fn word_motions_step_over_the_gap_and_then_the_word() { | |
| 97 | + let text = chars("one two three"); | |
| 98 | + assert_eq!(word_left(&text, 13), 8); | |
| 99 | + assert_eq!(word_left(&text, 8), 4); | |
| 100 | + assert_eq!(word_left(&text, 0), 0); | |
| 101 | + assert_eq!(word_right(&text, 0), 3); | |
| 102 | + assert_eq!(word_right(&text, 3), 7); | |
| 103 | + assert_eq!(word_right(&text, 13), 13); | |
| 104 | + } | |
| 105 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,105 @@ | |||
| 1 | +//! Key names, and the two word motions the entry needs. | ||
| 2 | +//! | ||
| 3 | +//! A key crosses this ABI as a string — `"ctrl+u"`, `"page-down"`, `"a"` — | ||
| 4 | +//! because that is the only shape jolt can match on, and because a name is | ||
| 5 | +//! something a test can type. The same names glimmer-tui's `k/match?` takes. | ||
| 6 | + | ||
| 7 | +/// Where the word before `at` starts. | ||
| 8 | +pub fn word_left(text: &[char], at: usize) -> usize { | ||
| 9 | + let mut i = at.min(text.len()); | ||
| 10 | + while i > 0 && text[i - 1].is_whitespace() { | ||
| 11 | + i -= 1; | ||
| 12 | + } | ||
| 13 | + while i > 0 && !text[i - 1].is_whitespace() { | ||
| 14 | + i -= 1; | ||
| 15 | + } | ||
| 16 | + i | ||
| 17 | +} | ||
| 18 | + | ||
| 19 | +/// Where the word after `at` ends. | ||
| 20 | +pub fn word_right(text: &[char], at: usize) -> usize { | ||
| 21 | + let mut i = at.min(text.len()); | ||
| 22 | + while i < text.len() && text[i].is_whitespace() { | ||
| 23 | + i += 1; | ||
| 24 | + } | ||
| 25 | + while i < text.len() && !text[i].is_whitespace() { | ||
| 26 | + i += 1; | ||
| 27 | + } | ||
| 28 | + i | ||
| 29 | +} | ||
| 30 | + | ||
| 31 | +#[cfg(feature = "terminal")] | ||
| 32 | +mod terminal { | ||
| 33 | + use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; | ||
| 34 | + | ||
| 35 | + /// The name for one key event, or `None` for a key with no name — a bare | ||
| 36 | + /// modifier press, or a media key nothing here would do anything with. | ||
| 37 | + pub fn name(event: KeyEvent) -> Option<String> { | ||
| 38 | + let mods = event.modifiers; | ||
| 39 | + let base = match event.code { | ||
| 40 | + KeyCode::Char(' ') => "space".to_owned(), | ||
| 41 | + KeyCode::Char(c) => { | ||
| 42 | + // Shift is already in the character the terminal sent; saying | ||
| 43 | + // it twice would make `A` into `shift+a`, which nothing wants | ||
| 44 | + // to match on. | ||
| 45 | + let mut name = c.to_string(); | ||
| 46 | + if mods.contains(KeyModifiers::CONTROL) || mods.contains(KeyModifiers::ALT) { | ||
| 47 | + name = c.to_lowercase().to_string(); | ||
| 48 | + } | ||
| 49 | + name | ||
| 50 | + } | ||
| 51 | + KeyCode::Enter => "enter".into(), | ||
| 52 | + KeyCode::Tab => "tab".into(), | ||
| 53 | + KeyCode::BackTab => return Some("shift+tab".into()), | ||
| 54 | + KeyCode::Backspace => "backspace".into(), | ||
| 55 | + KeyCode::Delete => "delete".into(), | ||
| 56 | + KeyCode::Insert => "insert".into(), | ||
| 57 | + KeyCode::Esc => "esc".into(), | ||
| 58 | + KeyCode::Up => "up".into(), | ||
| 59 | + KeyCode::Down => "down".into(), | ||
| 60 | + KeyCode::Left => "left".into(), | ||
| 61 | + KeyCode::Right => "right".into(), | ||
| 62 | + KeyCode::Home => "home".into(), | ||
| 63 | + KeyCode::End => "end".into(), | ||
| 64 | + KeyCode::PageUp => "page-up".into(), | ||
| 65 | + KeyCode::PageDown => "page-down".into(), | ||
| 66 | + KeyCode::F(n) => format!("f{n}"), | ||
| 67 | + _ => return None, | ||
| 68 | + }; | ||
| 69 | + let mut out = String::new(); | ||
| 70 | + if mods.contains(KeyModifiers::CONTROL) { | ||
| 71 | + out.push_str("ctrl+"); | ||
| 72 | + } | ||
| 73 | + if mods.contains(KeyModifiers::ALT) { | ||
| 74 | + out.push_str("alt+"); | ||
| 75 | + } | ||
| 76 | + if mods.contains(KeyModifiers::SHIFT) && !matches!(event.code, KeyCode::Char(_)) { | ||
| 77 | + out.push_str("shift+"); | ||
| 78 | + } | ||
| 79 | + out.push_str(&base); | ||
| 80 | + Some(out) | ||
| 81 | + } | ||
| 82 | +} | ||
| 83 | + | ||
| 84 | +#[cfg(feature = "terminal")] | ||
| 85 | +pub use terminal::name; | ||
| 86 | + | ||
| 87 | +#[cfg(test)] | ||
| 88 | +mod tests { | ||
| 89 | + use super::*; | ||
| 90 | + | ||
| 91 | + fn chars(text: &str) -> Vec<char> { | ||
| 92 | + text.chars().collect() | ||
| 93 | + } | ||
| 94 | + | ||
| 95 | + #[test] | ||
| 96 | + fn word_motions_step_over_the_gap_and_then_the_word() { | ||
| 97 | + let text = chars("one two three"); | ||
| 98 | + assert_eq!(word_left(&text, 13), 8); | ||
| 99 | + assert_eq!(word_left(&text, 8), 4); | ||
| 100 | + assert_eq!(word_left(&text, 0), 0); | ||
| 101 | + assert_eq!(word_right(&text, 0), 3); | ||
| 102 | + assert_eq!(word_right(&text, 3), 7); | ||
| 103 | + assert_eq!(word_right(&text, 13), 13); | ||
| 104 | + } | ||
| 105 | +} | ||
added
crates/jolt-tui/src/layout.rs +490 -0 | new file mode 100644 | ||
| @@ -0,0 +1,490 @@ | ||
| 1 | +//! Two sizes per node, and how a box shares out what it has. | |
| 2 | +//! | |
| 3 | +//! Every node answers a *natural* size — what it would like — and a *minimum* | |
| 4 | +//! — what it can survive on. A container hands out its natural sizes when there | |
| 5 | +//! is room, shrinks them proportionally toward the minimums when there is not, | |
| 6 | +//! and gives the surplus to whoever asked to expand. `:width-request` and | |
| 7 | +//! `:height-request` are a floor on both numbers, so asking for four rows gets | |
| 8 | +//! four rows even when space is short. | |
| 9 | +//! | |
| 10 | +//! Nothing here touches a terminal or the screen grid: sizes are arithmetic on | |
| 11 | +//! the tree, which is why the layout tests below need no TTY. | |
| 12 | + | |
| 13 | +use crate::screen::Rect; | |
| 14 | +use crate::tree::{Props, Tag, Tree}; | |
| 15 | + | |
| 16 | +/// How a child that is not filling its cross axis sits in the space it was | |
| 17 | +/// given. `:halign` and `:valign` in the props. | |
| 18 | +#[derive(Clone, Copy, Debug, PartialEq, Eq)] | |
| 19 | +pub enum Align { | |
| 20 | + Fill, | |
| 21 | + Start, | |
| 22 | + Center, | |
| 23 | + End, | |
| 24 | +} | |
| 25 | + | |
| 26 | +impl Align { | |
| 27 | + pub fn parse(text: &str) -> Self { | |
| 28 | + match text { | |
| 29 | + "start" => Self::Start, | |
| 30 | + "center" | "centre" => Self::Center, | |
| 31 | + "end" => Self::End, | |
| 32 | + _ => Self::Fill, | |
| 33 | + } | |
| 34 | + } | |
| 35 | + | |
| 36 | + /// Where a span of `size` sits inside `avail`. | |
| 37 | + fn offset(self, size: u16, avail: u16) -> u16 { | |
| 38 | + let slack = avail.saturating_sub(size); | |
| 39 | + match self { | |
| 40 | + Self::Fill | Self::Start => 0, | |
| 41 | + Self::Center => slack / 2, | |
| 42 | + Self::End => slack, | |
| 43 | + } | |
| 44 | + } | |
| 45 | +} | |
| 46 | + | |
| 47 | +/// Whether a box stacks its children across or down. | |
| 48 | +pub fn horizontal(props: &Props) -> bool { | |
| 49 | + props.str("orientation") == "horizontal" | |
| 50 | +} | |
| 51 | + | |
| 52 | +/// The cells a node gives up on each side before its content starts. `:margin` | |
| 53 | +/// and `:padding` are one inset here — a terminal cell has no border between | |
| 54 | +/// them to tell them apart, and a caller that sets both means both. | |
| 55 | +pub fn inset(tag: &Tag, props: &Props) -> u16 { | |
| 56 | + let own = props.cells("margin", 0) + props.cells("padding", 0); | |
| 57 | + // A frame — and an overlay, which is a frame that floats — spends a cell a | |
| 58 | + // side on its border. | |
| 59 | + own + if matches!(tag, Tag::Frame | Tag::Overlay) { | |
| 60 | + 1 | |
| 61 | + } else { | |
| 62 | + 0 | |
| 63 | + } | |
| 64 | +} | |
| 65 | + | |
| 66 | +/// Break `text` to `width` columns, on spaces where it can and mid-word where | |
| 67 | +/// it must. Explicit newlines are always breaks. | |
| 68 | +pub fn wrap(text: &str, width: u16) -> Vec<String> { | |
| 69 | + if width == 0 { | |
| 70 | + return Vec::new(); | |
| 71 | + } | |
| 72 | + let width = width as usize; | |
| 73 | + let mut lines = Vec::new(); | |
| 74 | + for paragraph in text.split('\n') { | |
| 75 | + let mut line = String::new(); | |
| 76 | + let mut len = 0usize; | |
| 77 | + for word in paragraph.split(' ') { | |
| 78 | + let word_len = word.chars().count(); | |
| 79 | + if len > 0 && len + 1 + word_len > width { | |
| 80 | + lines.push(std::mem::take(&mut line)); | |
| 81 | + len = 0; | |
| 82 | + } | |
| 83 | + if word_len > width { | |
| 84 | + // Longer than the whole line: break it where the line ends | |
| 85 | + // rather than let it run off the edge. | |
| 86 | + for ch in word.chars() { | |
| 87 | + if len == width { | |
| 88 | + lines.push(std::mem::take(&mut line)); | |
| 89 | + len = 0; | |
| 90 | + } | |
| 91 | + line.push(ch); | |
| 92 | + len += 1; | |
| 93 | + } | |
| 94 | + continue; | |
| 95 | + } | |
| 96 | + if len > 0 { | |
| 97 | + line.push(' '); | |
| 98 | + len += 1; | |
| 99 | + } | |
| 100 | + line.push_str(word); | |
| 101 | + len += word_len; | |
| 102 | + } | |
| 103 | + lines.push(line); | |
| 104 | + } | |
| 105 | + lines | |
| 106 | +} | |
| 107 | + | |
| 108 | +fn columns(text: &str) -> u16 { | |
| 109 | + text.split('\n') | |
| 110 | + .map(|line| line.chars().count()) | |
| 111 | + .max() | |
| 112 | + .unwrap_or(0) | |
| 113 | + .min(u16::MAX as usize) as u16 | |
| 114 | +} | |
| 115 | + | |
| 116 | +/// The longest single word — a label cannot usefully be narrower than this. | |
| 117 | +fn longest_word(text: &str) -> u16 { | |
| 118 | + text.split([' ', '\n']) | |
| 119 | + .map(|w| w.chars().count()) | |
| 120 | + .max() | |
| 121 | + .unwrap_or(0) | |
| 122 | + .min(u16::MAX as usize) as u16 | |
| 123 | +} | |
| 124 | + | |
| 125 | +/// The text an entry shows: its own, or its placeholder when it has none. | |
| 126 | +pub fn entry_text(props: &Props) -> String { | |
| 127 | + let text = props.str("text"); | |
| 128 | + if text.is_empty() { | |
| 129 | + props.str("placeholder").to_owned() | |
| 130 | + } else { | |
| 131 | + text.to_owned() | |
| 132 | + } | |
| 133 | +} | |
| 134 | + | |
| 135 | +/// A node's content size before its own request or inset is applied. | |
| 136 | +fn intrinsic_width(tree: &Tree, id: u32, minimum: bool) -> u16 { | |
| 137 | + let tag = tree.tag(id); | |
| 138 | + let props = tree.props(id); | |
| 139 | + let text = props.label(); | |
| 140 | + match tag { | |
| 141 | + Tag::Button => columns(text).saturating_add(4), | |
| 142 | + Tag::CheckButton => columns(text).saturating_add(4), | |
| 143 | + Tag::Entry => { | |
| 144 | + let want = columns(&entry_text(&props)).saturating_add(1).max(12); | |
| 145 | + if minimum { | |
| 146 | + want.min(6) | |
| 147 | + } else { | |
| 148 | + want | |
| 149 | + } | |
| 150 | + } | |
| 151 | + Tag::Label | Tag::Title | Tag::DimLabel => { | |
| 152 | + if minimum { | |
| 153 | + longest_word(text) | |
| 154 | + } else { | |
| 155 | + columns(text) | |
| 156 | + } | |
| 157 | + } | |
| 158 | + Tag::Separator => 1, | |
| 159 | + Tag::Spacer => props.cells("size", 1), | |
| 160 | + Tag::Progress => { | |
| 161 | + if minimum { | |
| 162 | + 4 | |
| 163 | + } else { | |
| 164 | + 20 | |
| 165 | + } | |
| 166 | + } | |
| 167 | + Tag::Spinner => 1, | |
| 168 | + Tag::Listbox => tree | |
| 169 | + .children(id) | |
| 170 | + .iter() | |
| 171 | + .map(|c| intrinsic_width(tree, *c, minimum).saturating_add(2)) | |
| 172 | + .max() | |
| 173 | + .unwrap_or(0), | |
| 174 | + // Every container measures its children the same way; only the axis | |
| 175 | + // the sum runs along differs. | |
| 176 | + Tag::Box | Tag::Window | Tag::Frame | Tag::Scroll | Tag::Overlay | Tag::Unknown(_) => { | |
| 177 | + let children = tree.children(id); | |
| 178 | + let spacing = props.cells("spacing", 0); | |
| 179 | + let sizes = children | |
| 180 | + .iter() | |
| 181 | + .map(|c| width(tree, *c, minimum)) | |
| 182 | + .collect::<Vec<_>>(); | |
| 183 | + let content = if horizontal(&props) && matches!(tag, Tag::Box) { | |
| 184 | + let gaps = spacing.saturating_mul(sizes.len().saturating_sub(1) as u16); | |
| 185 | + sizes.iter().fold(gaps, |a, b| a.saturating_add(*b)) | |
| 186 | + } else { | |
| 187 | + sizes.into_iter().max().unwrap_or(0) | |
| 188 | + }; | |
| 189 | + // A frame's heading sits in its top edge, so it is part of how wide | |
| 190 | + // the frame has to be — a box narrower than its own label reads as | |
| 191 | + // a truncated one. | |
| 192 | + if matches!(tag, Tag::Frame | Tag::Overlay) { | |
| 193 | + content.max(columns(props.label()).saturating_add(2)) | |
| 194 | + } else { | |
| 195 | + content | |
| 196 | + } | |
| 197 | + } | |
| 198 | + } | |
| 199 | +} | |
| 200 | + | |
| 201 | +/// A node's natural or minimum width, requests and insets included. | |
| 202 | +pub fn width(tree: &Tree, id: u32, minimum: bool) -> u16 { | |
| 203 | + let props = tree.props(id); | |
| 204 | + let pad = inset(&tree.tag(id), &props).saturating_mul(2); | |
| 205 | + let content = intrinsic_width(tree, id, minimum).saturating_add(pad); | |
| 206 | + content.max(props.cells("width-request", 0)) | |
| 207 | +} | |
| 208 | + | |
| 209 | +/// How tall `id` is when laid out `avail` columns wide. | |
| 210 | +/// | |
| 211 | +/// Height depends on width — that is what wrapping means — so there is no | |
| 212 | +/// natural height to ask for on its own. | |
| 213 | +pub fn height_for_width(tree: &Tree, id: u32, avail: u16) -> u16 { | |
| 214 | + let tag = tree.tag(id); | |
| 215 | + let props = tree.props(id); | |
| 216 | + let pad = inset(&tag, &props); | |
| 217 | + let inner = avail.saturating_sub(pad.saturating_mul(2)); | |
| 218 | + let content = match tag { | |
| 219 | + Tag::Label | Tag::Title | Tag::DimLabel => wrap(props.label(), inner).len() as u16, | |
| 220 | + Tag::Button | Tag::CheckButton | Tag::Separator | Tag::Progress | Tag::Spinner => 1, | |
| 221 | + Tag::Entry => props.cells("rows", 1).max(1), | |
| 222 | + Tag::Spacer => props.cells("size", 1), | |
| 223 | + Tag::Listbox => tree.child_count(id) as u16, | |
| 224 | + Tag::Box | Tag::Window | Tag::Frame | Tag::Scroll | Tag::Overlay | Tag::Unknown(_) => { | |
| 225 | + let children = tree.children(id); | |
| 226 | + let spacing = props.cells("spacing", 0); | |
| 227 | + if horizontal(&props) && matches!(tag, Tag::Box) { | |
| 228 | + // Across: each child is measured at the width it will get. | |
| 229 | + let shares = share(tree, id, inner, true); | |
| 230 | + children | |
| 231 | + .iter() | |
| 232 | + .zip(shares) | |
| 233 | + .map(|(c, w)| height_for_width(tree, *c, w)) | |
| 234 | + .max() | |
| 235 | + .unwrap_or(0) | |
| 236 | + } else { | |
| 237 | + let gaps = spacing.saturating_mul(children.len().saturating_sub(1) as u16); | |
| 238 | + children | |
| 239 | + .iter() | |
| 240 | + .map(|c| height_for_width(tree, *c, inner)) | |
| 241 | + .fold(gaps, |a, b| a.saturating_add(b)) | |
| 242 | + } | |
| 243 | + } | |
| 244 | + }; | |
| 245 | + content | |
| 246 | + .saturating_add(pad.saturating_mul(2)) | |
| 247 | + .max(props.cells("height-request", 0)) | |
| 248 | +} | |
| 249 | + | |
| 250 | +/// Share `avail` out among the children of `id` along one axis. | |
| 251 | +/// | |
| 252 | +/// `across` picks the axis: true for a horizontal box sharing columns, false | |
| 253 | +/// for a vertical one sharing rows. The rule is the same either way — natural | |
| 254 | +/// sizes first, shrink proportionally toward the minimums when short, and the | |
| 255 | +/// surplus to whoever set `:hexpand` / `:vexpand`. | |
| 256 | +pub fn share(tree: &Tree, id: u32, avail: u16, across: bool) -> Vec<u16> { | |
| 257 | + let children = tree.children(id); | |
| 258 | + if children.is_empty() { | |
| 259 | + return Vec::new(); | |
| 260 | + } | |
| 261 | + let props = tree.props(id); | |
| 262 | + let spacing = props.cells("spacing", 0); | |
| 263 | + let gaps = spacing.saturating_mul((children.len() - 1) as u16); | |
| 264 | + let room = avail.saturating_sub(gaps) as i64; | |
| 265 | + | |
| 266 | + let measure = |child: u32, minimum: bool| -> i64 { | |
| 267 | + if across { | |
| 268 | + width(tree, child, minimum) as i64 | |
| 269 | + } else { | |
| 270 | + // Down the page a child's height depends on the width it gets, | |
| 271 | + // which the caller has already fixed by the time it asks. | |
| 272 | + height_for_width(tree, child, avail) as i64 | |
| 273 | + } | |
| 274 | + }; | |
| 275 | + | |
| 276 | + let nat: Vec<i64> = children.iter().map(|c| measure(*c, false)).collect(); | |
| 277 | + let min: Vec<i64> = children | |
| 278 | + .iter() | |
| 279 | + .zip(&nat) | |
| 280 | + .map(|(c, n)| measure(*c, true).min(*n)) | |
| 281 | + .collect(); | |
| 282 | + let total: i64 = nat.iter().sum(); | |
| 283 | + let mut out = nat.clone(); | |
| 284 | + | |
| 285 | + if total > room { | |
| 286 | + // Short: take the overrun out of whatever each child is willing to give | |
| 287 | + // up, in proportion to how much that is. | |
| 288 | + let mut over = total - room; | |
| 289 | + let slack: i64 = nat.iter().zip(&min).map(|(n, m)| n - m).sum(); | |
| 290 | + if slack > 0 { | |
| 291 | + for i in 0..out.len() { | |
| 292 | + let give = ((nat[i] - min[i]) * over.min(slack)) / slack; | |
| 293 | + out[i] -= give; | |
| 294 | + } | |
| 295 | + over -= nat.iter().zip(&out).map(|(n, o)| n - o).sum::<i64>(); | |
| 296 | + } | |
| 297 | + // Rounding, and children with no slack at all: take the rest off the | |
| 298 | + // end, which is where a terminal clips anyway. | |
| 299 | + let mut i = out.len(); | |
| 300 | + while over > 0 && i > 0 { | |
| 301 | + i -= 1; | |
| 302 | + let give = (out[i] - min[i]).min(over); | |
| 303 | + out[i] -= give; | |
| 304 | + over -= give; | |
| 305 | + } | |
| 306 | + } else if total < room { | |
| 307 | + let key = if across { "hexpand" } else { "vexpand" }; | |
| 308 | + let greedy: Vec<usize> = children | |
| 309 | + .iter() | |
| 310 | + .enumerate() | |
| 311 | + .filter(|(_, c)| tree.props(**c).bool(key, false)) | |
| 312 | + .map(|(i, _)| i) | |
| 313 | + .collect(); | |
| 314 | + if !greedy.is_empty() { | |
| 315 | + let extra = room - total; | |
| 316 | + let each = extra / greedy.len() as i64; | |
| 317 | + let mut rest = extra % greedy.len() as i64; | |
| 318 | + for i in greedy { | |
| 319 | + out[i] += each + if rest > 0 { 1 } else { 0 }; | |
| 320 | + rest -= 1; | |
| 321 | + } | |
| 322 | + } | |
| 323 | + } | |
| 324 | + out.into_iter() | |
| 325 | + .map(|n| n.clamp(0, u16::MAX as i64) as u16) | |
| 326 | + .collect() | |
| 327 | +} | |
| 328 | + | |
| 329 | +/// The rect a child of `size` gets inside `avail` on its cross axis. | |
| 330 | +pub fn place(align: Align, size: u16, avail: u16) -> (u16, u16) { | |
| 331 | + match align { | |
| 332 | + Align::Fill => (0, avail), | |
| 333 | + other => { | |
| 334 | + let size = size.min(avail); | |
| 335 | + (other.offset(size, avail), size) | |
| 336 | + } | |
| 337 | + } | |
| 338 | +} | |
| 339 | + | |
| 340 | +/// Lay the children of a box out inside `area`. | |
| 341 | +pub fn children_rects(tree: &Tree, id: u32, area: Rect) -> Vec<Rect> { | |
| 342 | + let props = tree.props(id); | |
| 343 | + let across = horizontal(&props) && matches!(tree.tag(id), Tag::Box); | |
| 344 | + let spacing = props.cells("spacing", 0); | |
| 345 | + let children = tree.children(id); | |
| 346 | + let shares = share(tree, id, if across { area.w } else { area.h }, across); | |
| 347 | + | |
| 348 | + let mut out = Vec::with_capacity(children.len()); | |
| 349 | + let mut at = 0u16; | |
| 350 | + for (child, main) in children.iter().zip(shares) { | |
| 351 | + let cprops = tree.props(*child); | |
| 352 | + let rect = if across { | |
| 353 | + let want = height_for_width(tree, *child, main).max(cprops.cells("height-request", 0)); | |
| 354 | + let (dy, h) = place(Align::parse(cprops.str("valign")), want, area.h); | |
| 355 | + Rect::new( | |
| 356 | + area.x.saturating_add(at), | |
| 357 | + area.y.saturating_add(dy), | |
| 358 | + main, | |
| 359 | + h, | |
| 360 | + ) | |
| 361 | + } else { | |
| 362 | + let want = width(tree, *child, false); | |
| 363 | + let (dx, w) = place(Align::parse(cprops.str("halign")), want, area.w); | |
| 364 | + Rect::new( | |
| 365 | + area.x.saturating_add(dx), | |
| 366 | + area.y.saturating_add(at), | |
| 367 | + w, | |
| 368 | + main, | |
| 369 | + ) | |
| 370 | + }; | |
| 371 | + out.push(rect); | |
| 372 | + at = at.saturating_add(main).saturating_add(spacing); | |
| 373 | + } | |
| 374 | + out | |
| 375 | +} | |
| 376 | + | |
| 377 | +#[cfg(test)] | |
| 378 | +mod tests { | |
| 379 | + use super::*; | |
| 380 | + use crate::tree::Value; | |
| 381 | + | |
| 382 | + fn label(tree: &mut Tree, parent: u32, text: &str) -> u32 { | |
| 383 | + let id = tree.new_node("label"); | |
| 384 | + tree.set(id, "label", Value::Str(text.into())); | |
| 385 | + tree.append(parent, id); | |
| 386 | + id | |
| 387 | + } | |
| 388 | + | |
| 389 | + #[test] | |
| 390 | + fn wrapping_breaks_on_spaces_and_mid_word_when_it_must() { | |
| 391 | + assert_eq!(wrap("one two three", 7), vec!["one two", "three"]); | |
| 392 | + assert_eq!( | |
| 393 | + wrap("antidisestablishment", 6), | |
| 394 | + vec!["antidi", "sestab", "lishme", "nt"] | |
| 395 | + ); | |
| 396 | + assert_eq!(wrap("a\nb", 10), vec!["a", "b"]); | |
| 397 | + } | |
| 398 | + | |
| 399 | + #[test] | |
| 400 | + fn a_label_is_as_wide_as_its_text_and_as_narrow_as_its_longest_word() { | |
| 401 | + let mut tree = Tree::new(); | |
| 402 | + let root = tree.root(); | |
| 403 | + let id = label(&mut tree, root, "one two three"); | |
| 404 | + assert_eq!(width(&tree, id, false), 13); | |
| 405 | + assert_eq!(width(&tree, id, true), 5); | |
| 406 | + assert_eq!(height_for_width(&tree, id, 7), 2); | |
| 407 | + } | |
| 408 | + | |
| 409 | + #[test] | |
| 410 | + fn a_width_request_is_a_floor_on_both_sizes() { | |
| 411 | + let mut tree = Tree::new(); | |
| 412 | + let root = tree.root(); | |
| 413 | + let id = label(&mut tree, root, "hi"); | |
| 414 | + tree.set(id, "width-request", Value::Num(20.0)); | |
| 415 | + assert_eq!(width(&tree, id, false), 20); | |
| 416 | + assert_eq!(width(&tree, id, true), 20); | |
| 417 | + } | |
| 418 | + | |
| 419 | + #[test] | |
| 420 | + fn a_height_request_of_four_rows_gets_four_rows() { | |
| 421 | + let mut tree = Tree::new(); | |
| 422 | + let root = tree.root(); | |
| 423 | + let id = label(&mut tree, root, "hi"); | |
| 424 | + tree.set(id, "height-request", Value::Num(4.0)); | |
| 425 | + assert_eq!(height_for_width(&tree, id, 10), 4); | |
| 426 | + } | |
| 427 | + | |
| 428 | + #[test] | |
| 429 | + fn a_horizontal_box_gives_the_surplus_to_whoever_expands() { | |
| 430 | + let mut tree = Tree::new(); | |
| 431 | + let row = tree.new_node("hbox"); | |
| 432 | + tree.set(row, "orientation", Value::Str("horizontal".into())); | |
| 433 | + let root = tree.root(); | |
| 434 | + tree.append(root, row); | |
| 435 | + let a = label(&mut tree, row, "aa"); | |
| 436 | + let b = label(&mut tree, row, "bb"); | |
| 437 | + tree.set(b, "hexpand", Value::Bool(true)); | |
| 438 | + assert_eq!(share(&tree, row, 20, true), vec![2, 18]); | |
| 439 | + let _ = a; | |
| 440 | + } | |
| 441 | + | |
| 442 | + #[test] | |
| 443 | + fn a_short_box_shrinks_toward_the_minimums_rather_than_clipping_the_first_child() { | |
| 444 | + let mut tree = Tree::new(); | |
| 445 | + let row = tree.new_node("hbox"); | |
| 446 | + tree.set(row, "orientation", Value::Str("horizontal".into())); | |
| 447 | + let root = tree.root(); | |
| 448 | + tree.append(root, row); | |
| 449 | + label(&mut tree, row, "one two"); | |
| 450 | + label(&mut tree, row, "three four"); | |
| 451 | + // 17 natural, 10 offered: both give up some, neither goes under its | |
| 452 | + // longest word. | |
| 453 | + let shares = share(&tree, row, 10, true); | |
| 454 | + assert_eq!(shares.iter().sum::<u16>(), 10); | |
| 455 | + assert!(shares[0] >= 3 && shares[1] >= 5, "{shares:?}"); | |
| 456 | + } | |
| 457 | + | |
| 458 | + #[test] | |
| 459 | + fn spacing_comes_off_the_room_before_it_is_shared() { | |
| 460 | + let mut tree = Tree::new(); | |
| 461 | + let row = tree.new_node("hbox"); | |
| 462 | + tree.set(row, "orientation", Value::Str("horizontal".into())); | |
| 463 | + tree.set(row, "spacing", Value::Num(2.0)); | |
| 464 | + let root = tree.root(); | |
| 465 | + tree.append(root, row); | |
| 466 | + let a = label(&mut tree, row, "aa"); | |
| 467 | + let b = label(&mut tree, row, "bb"); | |
| 468 | + tree.set(a, "hexpand", Value::Bool(true)); | |
| 469 | + tree.set(b, "hexpand", Value::Bool(true)); | |
| 470 | + assert_eq!(share(&tree, row, 12, true), vec![5, 5]); | |
| 471 | + } | |
| 472 | + | |
| 473 | + #[test] | |
| 474 | + fn a_centred_child_sits_in_the_middle_of_its_row() { | |
| 475 | + assert_eq!(place(Align::Center, 4, 10), (3, 4)); | |
| 476 | + assert_eq!(place(Align::End, 4, 10), (6, 4)); | |
| 477 | + assert_eq!(place(Align::Fill, 4, 10), (0, 10)); | |
| 478 | + } | |
| 479 | + | |
| 480 | + #[test] | |
| 481 | + fn a_frame_spends_a_cell_a_side_on_its_border() { | |
| 482 | + let mut tree = Tree::new(); | |
| 483 | + let frame = tree.new_node("frame"); | |
| 484 | + let root = tree.root(); | |
| 485 | + tree.append(root, frame); | |
| 486 | + label(&mut tree, frame, "hi"); | |
| 487 | + assert_eq!(width(&tree, frame, false), 4); | |
| 488 | + assert_eq!(height_for_width(&tree, frame, 4), 3); | |
| 489 | + } | |
| 490 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,490 @@ | |||
| 1 | +//! Two sizes per node, and how a box shares out what it has. | ||
| 2 | +//! | ||
| 3 | +//! Every node answers a *natural* size — what it would like — and a *minimum* | ||
| 4 | +//! — what it can survive on. A container hands out its natural sizes when there | ||
| 5 | +//! is room, shrinks them proportionally toward the minimums when there is not, | ||
| 6 | +//! and gives the surplus to whoever asked to expand. `:width-request` and | ||
| 7 | +//! `:height-request` are a floor on both numbers, so asking for four rows gets | ||
| 8 | +//! four rows even when space is short. | ||
| 9 | +//! | ||
| 10 | +//! Nothing here touches a terminal or the screen grid: sizes are arithmetic on | ||
| 11 | +//! the tree, which is why the layout tests below need no TTY. | ||
| 12 | + | ||
| 13 | +use crate::screen::Rect; | ||
| 14 | +use crate::tree::{Props, Tag, Tree}; | ||
| 15 | + | ||
| 16 | +/// How a child that is not filling its cross axis sits in the space it was | ||
| 17 | +/// given. `:halign` and `:valign` in the props. | ||
| 18 | +#[derive(Clone, Copy, Debug, PartialEq, Eq)] | ||
| 19 | +pub enum Align { | ||
| 20 | + Fill, | ||
| 21 | + Start, | ||
| 22 | + Center, | ||
| 23 | + End, | ||
| 24 | +} | ||
| 25 | + | ||
| 26 | +impl Align { | ||
| 27 | + pub fn parse(text: &str) -> Self { | ||
| 28 | + match text { | ||
| 29 | + "start" => Self::Start, | ||
| 30 | + "center" | "centre" => Self::Center, | ||
| 31 | + "end" => Self::End, | ||
| 32 | + _ => Self::Fill, | ||
| 33 | + } | ||
| 34 | + } | ||
| 35 | + | ||
| 36 | + /// Where a span of `size` sits inside `avail`. | ||
| 37 | + fn offset(self, size: u16, avail: u16) -> u16 { | ||
| 38 | + let slack = avail.saturating_sub(size); | ||
| 39 | + match self { | ||
| 40 | + Self::Fill | Self::Start => 0, | ||
| 41 | + Self::Center => slack / 2, | ||
| 42 | + Self::End => slack, | ||
| 43 | + } | ||
| 44 | + } | ||
| 45 | +} | ||
| 46 | + | ||
| 47 | +/// Whether a box stacks its children across or down. | ||
| 48 | +pub fn horizontal(props: &Props) -> bool { | ||
| 49 | + props.str("orientation") == "horizontal" | ||
| 50 | +} | ||
| 51 | + | ||
| 52 | +/// The cells a node gives up on each side before its content starts. `:margin` | ||
| 53 | +/// and `:padding` are one inset here — a terminal cell has no border between | ||
| 54 | +/// them to tell them apart, and a caller that sets both means both. | ||
| 55 | +pub fn inset(tag: &Tag, props: &Props) -> u16 { | ||
| 56 | + let own = props.cells("margin", 0) + props.cells("padding", 0); | ||
| 57 | + // A frame — and an overlay, which is a frame that floats — spends a cell a | ||
| 58 | + // side on its border. | ||
| 59 | + own + if matches!(tag, Tag::Frame | Tag::Overlay) { | ||
| 60 | + 1 | ||
| 61 | + } else { | ||
| 62 | + 0 | ||
| 63 | + } | ||
| 64 | +} | ||
| 65 | + | ||
| 66 | +/// Break `text` to `width` columns, on spaces where it can and mid-word where | ||
| 67 | +/// it must. Explicit newlines are always breaks. | ||
| 68 | +pub fn wrap(text: &str, width: u16) -> Vec<String> { | ||
| 69 | + if width == 0 { | ||
| 70 | + return Vec::new(); | ||
| 71 | + } | ||
| 72 | + let width = width as usize; | ||
| 73 | + let mut lines = Vec::new(); | ||
| 74 | + for paragraph in text.split('\n') { | ||
| 75 | + let mut line = String::new(); | ||
| 76 | + let mut len = 0usize; | ||
| 77 | + for word in paragraph.split(' ') { | ||
| 78 | + let word_len = word.chars().count(); | ||
| 79 | + if len > 0 && len + 1 + word_len > width { | ||
| 80 | + lines.push(std::mem::take(&mut line)); | ||
| 81 | + len = 0; | ||
| 82 | + } | ||
| 83 | + if word_len > width { | ||
| 84 | + // Longer than the whole line: break it where the line ends | ||
| 85 | + // rather than let it run off the edge. | ||
| 86 | + for ch in word.chars() { | ||
| 87 | + if len == width { | ||
| 88 | + lines.push(std::mem::take(&mut line)); | ||
| 89 | + len = 0; | ||
| 90 | + } | ||
| 91 | + line.push(ch); | ||
| 92 | + len += 1; | ||
| 93 | + } | ||
| 94 | + continue; | ||
| 95 | + } | ||
| 96 | + if len > 0 { | ||
| 97 | + line.push(' '); | ||
| 98 | + len += 1; | ||
| 99 | + } | ||
| 100 | + line.push_str(word); | ||
| 101 | + len += word_len; | ||
| 102 | + } | ||
| 103 | + lines.push(line); | ||
| 104 | + } | ||
| 105 | + lines | ||
| 106 | +} | ||
| 107 | + | ||
| 108 | +fn columns(text: &str) -> u16 { | ||
| 109 | + text.split('\n') | ||
| 110 | + .map(|line| line.chars().count()) | ||
| 111 | + .max() | ||
| 112 | + .unwrap_or(0) | ||
| 113 | + .min(u16::MAX as usize) as u16 | ||
| 114 | +} | ||
| 115 | + | ||
| 116 | +/// The longest single word — a label cannot usefully be narrower than this. | ||
| 117 | +fn longest_word(text: &str) -> u16 { | ||
| 118 | + text.split([' ', '\n']) | ||
| 119 | + .map(|w| w.chars().count()) | ||
| 120 | + .max() | ||
| 121 | + .unwrap_or(0) | ||
| 122 | + .min(u16::MAX as usize) as u16 | ||
| 123 | +} | ||
| 124 | + | ||
| 125 | +/// The text an entry shows: its own, or its placeholder when it has none. | ||
| 126 | +pub fn entry_text(props: &Props) -> String { | ||
| 127 | + let text = props.str("text"); | ||
| 128 | + if text.is_empty() { | ||
| 129 | + props.str("placeholder").to_owned() | ||
| 130 | + } else { | ||
| 131 | + text.to_owned() | ||
| 132 | + } | ||
| 133 | +} | ||
| 134 | + | ||
| 135 | +/// A node's content size before its own request or inset is applied. | ||
| 136 | +fn intrinsic_width(tree: &Tree, id: u32, minimum: bool) -> u16 { | ||
| 137 | + let tag = tree.tag(id); | ||
| 138 | + let props = tree.props(id); | ||
| 139 | + let text = props.label(); | ||
| 140 | + match tag { | ||
| 141 | + Tag::Button => columns(text).saturating_add(4), | ||
| 142 | + Tag::CheckButton => columns(text).saturating_add(4), | ||
| 143 | + Tag::Entry => { | ||
| 144 | + let want = columns(&entry_text(&props)).saturating_add(1).max(12); | ||
| 145 | + if minimum { | ||
| 146 | + want.min(6) | ||
| 147 | + } else { | ||
| 148 | + want | ||
| 149 | + } | ||
| 150 | + } | ||
| 151 | + Tag::Label | Tag::Title | Tag::DimLabel => { | ||
| 152 | + if minimum { | ||
| 153 | + longest_word(text) | ||
| 154 | + } else { | ||
| 155 | + columns(text) | ||
| 156 | + } | ||
| 157 | + } | ||
| 158 | + Tag::Separator => 1, | ||
| 159 | + Tag::Spacer => props.cells("size", 1), | ||
| 160 | + Tag::Progress => { | ||
| 161 | + if minimum { | ||
| 162 | + 4 | ||
| 163 | + } else { | ||
| 164 | + 20 | ||
| 165 | + } | ||
| 166 | + } | ||
| 167 | + Tag::Spinner => 1, | ||
| 168 | + Tag::Listbox => tree | ||
| 169 | + .children(id) | ||
| 170 | + .iter() | ||
| 171 | + .map(|c| intrinsic_width(tree, *c, minimum).saturating_add(2)) | ||
| 172 | + .max() | ||
| 173 | + .unwrap_or(0), | ||
| 174 | + // Every container measures its children the same way; only the axis | ||
| 175 | + // the sum runs along differs. | ||
| 176 | + Tag::Box | Tag::Window | Tag::Frame | Tag::Scroll | Tag::Overlay | Tag::Unknown(_) => { | ||
| 177 | + let children = tree.children(id); | ||
| 178 | + let spacing = props.cells("spacing", 0); | ||
| 179 | + let sizes = children | ||
| 180 | + .iter() | ||
| 181 | + .map(|c| width(tree, *c, minimum)) | ||
| 182 | + .collect::<Vec<_>>(); | ||
| 183 | + let content = if horizontal(&props) && matches!(tag, Tag::Box) { | ||
| 184 | + let gaps = spacing.saturating_mul(sizes.len().saturating_sub(1) as u16); | ||
| 185 | + sizes.iter().fold(gaps, |a, b| a.saturating_add(*b)) | ||
| 186 | + } else { | ||
| 187 | + sizes.into_iter().max().unwrap_or(0) | ||
| 188 | + }; | ||
| 189 | + // A frame's heading sits in its top edge, so it is part of how wide | ||
| 190 | + // the frame has to be — a box narrower than its own label reads as | ||
| 191 | + // a truncated one. | ||
| 192 | + if matches!(tag, Tag::Frame | Tag::Overlay) { | ||
| 193 | + content.max(columns(props.label()).saturating_add(2)) | ||
| 194 | + } else { | ||
| 195 | + content | ||
| 196 | + } | ||
| 197 | + } | ||
| 198 | + } | ||
| 199 | +} | ||
| 200 | + | ||
| 201 | +/// A node's natural or minimum width, requests and insets included. | ||
| 202 | +pub fn width(tree: &Tree, id: u32, minimum: bool) -> u16 { | ||
| 203 | + let props = tree.props(id); | ||
| 204 | + let pad = inset(&tree.tag(id), &props).saturating_mul(2); | ||
| 205 | + let content = intrinsic_width(tree, id, minimum).saturating_add(pad); | ||
| 206 | + content.max(props.cells("width-request", 0)) | ||
| 207 | +} | ||
| 208 | + | ||
| 209 | +/// How tall `id` is when laid out `avail` columns wide. | ||
| 210 | +/// | ||
| 211 | +/// Height depends on width — that is what wrapping means — so there is no | ||
| 212 | +/// natural height to ask for on its own. | ||
| 213 | +pub fn height_for_width(tree: &Tree, id: u32, avail: u16) -> u16 { | ||
| 214 | + let tag = tree.tag(id); | ||
| 215 | + let props = tree.props(id); | ||
| 216 | + let pad = inset(&tag, &props); | ||
| 217 | + let inner = avail.saturating_sub(pad.saturating_mul(2)); | ||
| 218 | + let content = match tag { | ||
| 219 | + Tag::Label | Tag::Title | Tag::DimLabel => wrap(props.label(), inner).len() as u16, | ||
| 220 | + Tag::Button | Tag::CheckButton | Tag::Separator | Tag::Progress | Tag::Spinner => 1, | ||
| 221 | + Tag::Entry => props.cells("rows", 1).max(1), | ||
| 222 | + Tag::Spacer => props.cells("size", 1), | ||
| 223 | + Tag::Listbox => tree.child_count(id) as u16, | ||
| 224 | + Tag::Box | Tag::Window | Tag::Frame | Tag::Scroll | Tag::Overlay | Tag::Unknown(_) => { | ||
| 225 | + let children = tree.children(id); | ||
| 226 | + let spacing = props.cells("spacing", 0); | ||
| 227 | + if horizontal(&props) && matches!(tag, Tag::Box) { | ||
| 228 | + // Across: each child is measured at the width it will get. | ||
| 229 | + let shares = share(tree, id, inner, true); | ||
| 230 | + children | ||
| 231 | + .iter() | ||
| 232 | + .zip(shares) | ||
| 233 | + .map(|(c, w)| height_for_width(tree, *c, w)) | ||
| 234 | + .max() | ||
| 235 | + .unwrap_or(0) | ||
| 236 | + } else { | ||
| 237 | + let gaps = spacing.saturating_mul(children.len().saturating_sub(1) as u16); | ||
| 238 | + children | ||
| 239 | + .iter() | ||
| 240 | + .map(|c| height_for_width(tree, *c, inner)) | ||
| 241 | + .fold(gaps, |a, b| a.saturating_add(b)) | ||
| 242 | + } | ||
| 243 | + } | ||
| 244 | + }; | ||
| 245 | + content | ||
| 246 | + .saturating_add(pad.saturating_mul(2)) | ||
| 247 | + .max(props.cells("height-request", 0)) | ||
| 248 | +} | ||
| 249 | + | ||
| 250 | +/// Share `avail` out among the children of `id` along one axis. | ||
| 251 | +/// | ||
| 252 | +/// `across` picks the axis: true for a horizontal box sharing columns, false | ||
| 253 | +/// for a vertical one sharing rows. The rule is the same either way — natural | ||
| 254 | +/// sizes first, shrink proportionally toward the minimums when short, and the | ||
| 255 | +/// surplus to whoever set `:hexpand` / `:vexpand`. | ||
| 256 | +pub fn share(tree: &Tree, id: u32, avail: u16, across: bool) -> Vec<u16> { | ||
| 257 | + let children = tree.children(id); | ||
| 258 | + if children.is_empty() { | ||
| 259 | + return Vec::new(); | ||
| 260 | + } | ||
| 261 | + let props = tree.props(id); | ||
| 262 | + let spacing = props.cells("spacing", 0); | ||
| 263 | + let gaps = spacing.saturating_mul((children.len() - 1) as u16); | ||
| 264 | + let room = avail.saturating_sub(gaps) as i64; | ||
| 265 | + | ||
| 266 | + let measure = |child: u32, minimum: bool| -> i64 { | ||
| 267 | + if across { | ||
| 268 | + width(tree, child, minimum) as i64 | ||
| 269 | + } else { | ||
| 270 | + // Down the page a child's height depends on the width it gets, | ||
| 271 | + // which the caller has already fixed by the time it asks. | ||
| 272 | + height_for_width(tree, child, avail) as i64 | ||
| 273 | + } | ||
| 274 | + }; | ||
| 275 | + | ||
| 276 | + let nat: Vec<i64> = children.iter().map(|c| measure(*c, false)).collect(); | ||
| 277 | + let min: Vec<i64> = children | ||
| 278 | + .iter() | ||
| 279 | + .zip(&nat) | ||
| 280 | + .map(|(c, n)| measure(*c, true).min(*n)) | ||
| 281 | + .collect(); | ||
| 282 | + let total: i64 = nat.iter().sum(); | ||
| 283 | + let mut out = nat.clone(); | ||
| 284 | + | ||
| 285 | + if total > room { | ||
| 286 | + // Short: take the overrun out of whatever each child is willing to give | ||
| 287 | + // up, in proportion to how much that is. | ||
| 288 | + let mut over = total - room; | ||
| 289 | + let slack: i64 = nat.iter().zip(&min).map(|(n, m)| n - m).sum(); | ||
| 290 | + if slack > 0 { | ||
| 291 | + for i in 0..out.len() { | ||
| 292 | + let give = ((nat[i] - min[i]) * over.min(slack)) / slack; | ||
| 293 | + out[i] -= give; | ||
| 294 | + } | ||
| 295 | + over -= nat.iter().zip(&out).map(|(n, o)| n - o).sum::<i64>(); | ||
| 296 | + } | ||
| 297 | + // Rounding, and children with no slack at all: take the rest off the | ||
| 298 | + // end, which is where a terminal clips anyway. | ||
| 299 | + let mut i = out.len(); | ||
| 300 | + while over > 0 && i > 0 { | ||
| 301 | + i -= 1; | ||
| 302 | + let give = (out[i] - min[i]).min(over); | ||
| 303 | + out[i] -= give; | ||
| 304 | + over -= give; | ||
| 305 | + } | ||
| 306 | + } else if total < room { | ||
| 307 | + let key = if across { "hexpand" } else { "vexpand" }; | ||
| 308 | + let greedy: Vec<usize> = children | ||
| 309 | + .iter() | ||
| 310 | + .enumerate() | ||
| 311 | + .filter(|(_, c)| tree.props(**c).bool(key, false)) | ||
| 312 | + .map(|(i, _)| i) | ||
| 313 | + .collect(); | ||
| 314 | + if !greedy.is_empty() { | ||
| 315 | + let extra = room - total; | ||
| 316 | + let each = extra / greedy.len() as i64; | ||
| 317 | + let mut rest = extra % greedy.len() as i64; | ||
| 318 | + for i in greedy { | ||
| 319 | + out[i] += each + if rest > 0 { 1 } else { 0 }; | ||
| 320 | + rest -= 1; | ||
| 321 | + } | ||
| 322 | + } | ||
| 323 | + } | ||
| 324 | + out.into_iter() | ||
| 325 | + .map(|n| n.clamp(0, u16::MAX as i64) as u16) | ||
| 326 | + .collect() | ||
| 327 | +} | ||
| 328 | + | ||
| 329 | +/// The rect a child of `size` gets inside `avail` on its cross axis. | ||
| 330 | +pub fn place(align: Align, size: u16, avail: u16) -> (u16, u16) { | ||
| 331 | + match align { | ||
| 332 | + Align::Fill => (0, avail), | ||
| 333 | + other => { | ||
| 334 | + let size = size.min(avail); | ||
| 335 | + (other.offset(size, avail), size) | ||
| 336 | + } | ||
| 337 | + } | ||
| 338 | +} | ||
| 339 | + | ||
| 340 | +/// Lay the children of a box out inside `area`. | ||
| 341 | +pub fn children_rects(tree: &Tree, id: u32, area: Rect) -> Vec<Rect> { | ||
| 342 | + let props = tree.props(id); | ||
| 343 | + let across = horizontal(&props) && matches!(tree.tag(id), Tag::Box); | ||
| 344 | + let spacing = props.cells("spacing", 0); | ||
| 345 | + let children = tree.children(id); | ||
| 346 | + let shares = share(tree, id, if across { area.w } else { area.h }, across); | ||
| 347 | + | ||
| 348 | + let mut out = Vec::with_capacity(children.len()); | ||
| 349 | + let mut at = 0u16; | ||
| 350 | + for (child, main) in children.iter().zip(shares) { | ||
| 351 | + let cprops = tree.props(*child); | ||
| 352 | + let rect = if across { | ||
| 353 | + let want = height_for_width(tree, *child, main).max(cprops.cells("height-request", 0)); | ||
| 354 | + let (dy, h) = place(Align::parse(cprops.str("valign")), want, area.h); | ||
| 355 | + Rect::new( | ||
| 356 | + area.x.saturating_add(at), | ||
| 357 | + area.y.saturating_add(dy), | ||
| 358 | + main, | ||
| 359 | + h, | ||
| 360 | + ) | ||
| 361 | + } else { | ||
| 362 | + let want = width(tree, *child, false); | ||
| 363 | + let (dx, w) = place(Align::parse(cprops.str("halign")), want, area.w); | ||
| 364 | + Rect::new( | ||
| 365 | + area.x.saturating_add(dx), | ||
| 366 | + area.y.saturating_add(at), | ||
| 367 | + w, | ||
| 368 | + main, | ||
| 369 | + ) | ||
| 370 | + }; | ||
| 371 | + out.push(rect); | ||
| 372 | + at = at.saturating_add(main).saturating_add(spacing); | ||
| 373 | + } | ||
| 374 | + out | ||
| 375 | +} | ||
| 376 | + | ||
| 377 | +#[cfg(test)] | ||
| 378 | +mod tests { | ||
| 379 | + use super::*; | ||
| 380 | + use crate::tree::Value; | ||
| 381 | + | ||
| 382 | + fn label(tree: &mut Tree, parent: u32, text: &str) -> u32 { | ||
| 383 | + let id = tree.new_node("label"); | ||
| 384 | + tree.set(id, "label", Value::Str(text.into())); | ||
| 385 | + tree.append(parent, id); | ||
| 386 | + id | ||
| 387 | + } | ||
| 388 | + | ||
| 389 | + #[test] | ||
| 390 | + fn wrapping_breaks_on_spaces_and_mid_word_when_it_must() { | ||
| 391 | + assert_eq!(wrap("one two three", 7), vec!["one two", "three"]); | ||
| 392 | + assert_eq!( | ||
| 393 | + wrap("antidisestablishment", 6), | ||
| 394 | + vec!["antidi", "sestab", "lishme", "nt"] | ||
| 395 | + ); | ||
| 396 | + assert_eq!(wrap("a\nb", 10), vec!["a", "b"]); | ||
| 397 | + } | ||
| 398 | + | ||
| 399 | + #[test] | ||
| 400 | + fn a_label_is_as_wide_as_its_text_and_as_narrow_as_its_longest_word() { | ||
| 401 | + let mut tree = Tree::new(); | ||
| 402 | + let root = tree.root(); | ||
| 403 | + let id = label(&mut tree, root, "one two three"); | ||
| 404 | + assert_eq!(width(&tree, id, false), 13); | ||
| 405 | + assert_eq!(width(&tree, id, true), 5); | ||
| 406 | + assert_eq!(height_for_width(&tree, id, 7), 2); | ||
| 407 | + } | ||
| 408 | + | ||
| 409 | + #[test] | ||
| 410 | + fn a_width_request_is_a_floor_on_both_sizes() { | ||
| 411 | + let mut tree = Tree::new(); | ||
| 412 | + let root = tree.root(); | ||
| 413 | + let id = label(&mut tree, root, "hi"); | ||
| 414 | + tree.set(id, "width-request", Value::Num(20.0)); | ||
| 415 | + assert_eq!(width(&tree, id, false), 20); | ||
| 416 | + assert_eq!(width(&tree, id, true), 20); | ||
| 417 | + } | ||
| 418 | + | ||
| 419 | + #[test] | ||
| 420 | + fn a_height_request_of_four_rows_gets_four_rows() { | ||
| 421 | + let mut tree = Tree::new(); | ||
| 422 | + let root = tree.root(); | ||
| 423 | + let id = label(&mut tree, root, "hi"); | ||
| 424 | + tree.set(id, "height-request", Value::Num(4.0)); | ||
| 425 | + assert_eq!(height_for_width(&tree, id, 10), 4); | ||
| 426 | + } | ||
| 427 | + | ||
| 428 | + #[test] | ||
| 429 | + fn a_horizontal_box_gives_the_surplus_to_whoever_expands() { | ||
| 430 | + let mut tree = Tree::new(); | ||
| 431 | + let row = tree.new_node("hbox"); | ||
| 432 | + tree.set(row, "orientation", Value::Str("horizontal".into())); | ||
| 433 | + let root = tree.root(); | ||
| 434 | + tree.append(root, row); | ||
| 435 | + let a = label(&mut tree, row, "aa"); | ||
| 436 | + let b = label(&mut tree, row, "bb"); | ||
| 437 | + tree.set(b, "hexpand", Value::Bool(true)); | ||
| 438 | + assert_eq!(share(&tree, row, 20, true), vec![2, 18]); | ||
| 439 | + let _ = a; | ||
| 440 | + } | ||
| 441 | + | ||
| 442 | + #[test] | ||
| 443 | + fn a_short_box_shrinks_toward_the_minimums_rather_than_clipping_the_first_child() { | ||
| 444 | + let mut tree = Tree::new(); | ||
| 445 | + let row = tree.new_node("hbox"); | ||
| 446 | + tree.set(row, "orientation", Value::Str("horizontal".into())); | ||
| 447 | + let root = tree.root(); | ||
| 448 | + tree.append(root, row); | ||
| 449 | + label(&mut tree, row, "one two"); | ||
| 450 | + label(&mut tree, row, "three four"); | ||
| 451 | + // 17 natural, 10 offered: both give up some, neither goes under its | ||
| 452 | + // longest word. | ||
| 453 | + let shares = share(&tree, row, 10, true); | ||
| 454 | + assert_eq!(shares.iter().sum::<u16>(), 10); | ||
| 455 | + assert!(shares[0] >= 3 && shares[1] >= 5, "{shares:?}"); | ||
| 456 | + } | ||
| 457 | + | ||
| 458 | + #[test] | ||
| 459 | + fn spacing_comes_off_the_room_before_it_is_shared() { | ||
| 460 | + let mut tree = Tree::new(); | ||
| 461 | + let row = tree.new_node("hbox"); | ||
| 462 | + tree.set(row, "orientation", Value::Str("horizontal".into())); | ||
| 463 | + tree.set(row, "spacing", Value::Num(2.0)); | ||
| 464 | + let root = tree.root(); | ||
| 465 | + tree.append(root, row); | ||
| 466 | + let a = label(&mut tree, row, "aa"); | ||
| 467 | + let b = label(&mut tree, row, "bb"); | ||
| 468 | + tree.set(a, "hexpand", Value::Bool(true)); | ||
| 469 | + tree.set(b, "hexpand", Value::Bool(true)); | ||
| 470 | + assert_eq!(share(&tree, row, 12, true), vec![5, 5]); | ||
| 471 | + } | ||
| 472 | + | ||
| 473 | + #[test] | ||
| 474 | + fn a_centred_child_sits_in_the_middle_of_its_row() { | ||
| 475 | + assert_eq!(place(Align::Center, 4, 10), (3, 4)); | ||
| 476 | + assert_eq!(place(Align::End, 4, 10), (6, 4)); | ||
| 477 | + assert_eq!(place(Align::Fill, 4, 10), (0, 10)); | ||
| 478 | + } | ||
| 479 | + | ||
| 480 | + #[test] | ||
| 481 | + fn a_frame_spends_a_cell_a_side_on_its_border() { | ||
| 482 | + let mut tree = Tree::new(); | ||
| 483 | + let frame = tree.new_node("frame"); | ||
| 484 | + let root = tree.root(); | ||
| 485 | + tree.append(root, frame); | ||
| 486 | + label(&mut tree, frame, "hi"); | ||
| 487 | + assert_eq!(width(&tree, frame, false), 4); | ||
| 488 | + assert_eq!(height_for_width(&tree, frame, 4), 3); | ||
| 489 | + } | ||
| 490 | +} | ||
added
crates/jolt-tui/src/lib.rs +506 -0 | new file mode 100644 | ||
| @@ -0,0 +1,506 @@ | ||
| 1 | +//! A terminal backend for glimmer, behind a C ABI — `libjolttui.so`. | |
| 2 | +//! | |
| 3 | +//! [`glimmer-tui`](https://github.com/jolt-lang/glimmer-tui) is the design this | |
| 4 | +//! follows: the same tags, the same props, the same keyboard, and the same rule | |
| 5 | +//! that painting goes through a grid so a test needs no terminal. What is | |
| 6 | +//! different is where the widget layer lives. There it is jolt over ncurses; | |
| 7 | +//! here it is Rust behind the same retained-tree ABI `libvidya` already | |
| 8 | +//! exports, so one glimmer backend on the jolt side can drive a GPU window or a | |
| 9 | +//! terminal by naming a different shared object. | |
| 10 | +//! | |
| 11 | +//! That split is the point. A reconciler needs widgets to patch, and a terminal | |
| 12 | +//! has none — so the tree lives down here, and FFI traffic tracks *edits* | |
| 13 | +//! rather than frames: a static screen costs no crossings per frame, and only | |
| 14 | +//! what the reconciler actually changed is sent. | |
| 15 | +//! | |
| 16 | +//! Rules inherited from this workspace's ABI: | |
| 17 | +//! | |
| 18 | +//! * one session per process; | |
| 19 | +//! * every call stays on the thread that opened it — the session lives in | |
| 20 | +//! thread-local storage, so a call from another thread is inert rather than | |
| 21 | +//! unsound; | |
| 22 | +//! * only integers, doubles, and UTF-8 byte strings cross, and a returned | |
| 23 | +//! string is borrowed until the next one of its family; | |
| 24 | +//! * nothing calls back. Interactions queue, and the caller polls. | |
| 25 | +//! | |
| 26 | +//! Handlers never cross the boundary: a node reports that it was clicked, and | |
| 27 | +//! the caller looks up whose `:on-click` that was. | |
| 28 | + | |
| 29 | +// Without the terminal feature the flush path is gone, and with it the only | |
| 30 | +// caller of a handful of screen and session accessors. They are the ABI's | |
| 31 | +// vocabulary, not dead code, so a headless build does not warn about them. | |
| 32 | +#![cfg_attr(not(feature = "terminal"), allow(dead_code))] | |
| 33 | + | |
| 34 | +mod keys; | |
| 35 | +mod layout; | |
| 36 | +mod paint; | |
| 37 | +mod screen; | |
| 38 | +#[cfg(feature = "terminal")] | |
| 39 | +mod term; | |
| 40 | +mod tree; | |
| 41 | +mod ui; | |
| 42 | + | |
| 43 | +#[cfg(test)] | |
| 44 | +mod tests; | |
| 45 | + | |
| 46 | +use std::cell::RefCell; | |
| 47 | +use std::ffi::{c_char, c_double, c_int}; | |
| 48 | + | |
| 49 | +use jolt_abi::{borrowed, empty_str, guard, Scratch}; | |
| 50 | +use tree::Value; | |
| 51 | +use ui::Ui; | |
| 52 | + | |
| 53 | +/// The reads: a prop, a tag, a line of the screen. One scratch, so a caller | |
| 54 | +/// holding a tag pointer across a prop read gets the documented lifetime and | |
| 55 | +/// not a surprise. | |
| 56 | +static READS: Scratch = Scratch::new(); | |
| 57 | +/// Dumps are their own family: a dump is usually being printed beside the | |
| 58 | +/// props it mentions. | |
| 59 | +static DUMPS: Scratch = Scratch::new(); | |
| 60 | +/// The event name and the event text are read one after the other by every | |
| 61 | +/// caller there will ever be, so they cannot share a scratch. | |
| 62 | +static NAMES: Scratch = Scratch::new(); | |
| 63 | +static EVENTS: Scratch = Scratch::new(); | |
| 64 | + | |
| 65 | +struct Session { | |
| 66 | + ui: Ui, | |
| 67 | + #[cfg(feature = "terminal")] | |
| 68 | + term: Option<term::Term>, | |
| 69 | +} | |
| 70 | + | |
| 71 | +thread_local! { | |
| 72 | + /// The process's session, owned by the thread that opened it. | |
| 73 | + static SESSION: RefCell<Option<Session>> = const { RefCell::new(None) }; | |
| 74 | +} | |
| 75 | + | |
| 76 | +fn with<R: Copy>(fallback: R, f: impl FnOnce(&mut Session) -> R) -> R { | |
| 77 | + guard(fallback, || { | |
| 78 | + SESSION.with_borrow_mut(|slot| match slot.as_mut() { | |
| 79 | + Some(session) => f(session), | |
| 80 | + None => fallback, | |
| 81 | + }) | |
| 82 | + }) | |
| 83 | +} | |
| 84 | + | |
| 85 | +/// Most of this ABI is a call on the tree with a session around it. | |
| 86 | +fn with_ui<R: Copy>(fallback: R, f: impl FnOnce(&mut Ui) -> R) -> R { | |
| 87 | + with(fallback, |session| f(&mut session.ui)) | |
| 88 | +} | |
| 89 | + | |
| 90 | +// ── the session ───────────────────────────────────────────────────────────── | |
| 91 | + | |
| 92 | +/// Take the terminal. `mouse` non-zero turns on mouse reporting. 1 on success, | |
| 93 | +/// 0 if a session is already open or the terminal refused raw mode. | |
| 94 | +#[no_mangle] | |
| 95 | +pub extern "C" fn tui_open(mouse: c_int) -> c_int { | |
| 96 | + guard(0, || { | |
| 97 | + SESSION.with_borrow_mut(|slot| { | |
| 98 | + if slot.is_some() { | |
| 99 | + log::error!("jolt-tui: a session is already open"); | |
| 100 | + return 0; | |
| 101 | + } | |
| 102 | + #[cfg(feature = "terminal")] | |
| 103 | + { | |
| 104 | + match term::Term::open(mouse != 0) { | |
| 105 | + Ok(term) => { | |
| 106 | + let (w, h) = term.size(); | |
| 107 | + *slot = Some(Session { | |
| 108 | + ui: Ui::new(w, h), | |
| 109 | + term: Some(term), | |
| 110 | + }); | |
| 111 | + 1 | |
| 112 | + } | |
| 113 | + Err(e) => { | |
| 114 | + log::error!("jolt-tui: could not take the terminal: {e}"); | |
| 115 | + 0 | |
| 116 | + } | |
| 117 | + } | |
| 118 | + } | |
| 119 | + #[cfg(not(feature = "terminal"))] | |
| 120 | + { | |
| 121 | + let _ = mouse; | |
| 122 | + log::error!("jolt-tui: built without the terminal feature"); | |
| 123 | + 0 | |
| 124 | + } | |
| 125 | + }) | |
| 126 | + }) | |
| 127 | +} | |
| 128 | + | |
| 129 | +/// Open a session with no terminal at all, at a fixed size. | |
| 130 | +/// | |
| 131 | +/// The whole widget layer works here — layout, painting, focus, keys fed with | |
| 132 | +/// `tui_feed_key` — and `tui_screen_line` reads the result back. This is what a | |
| 133 | +/// test suite and CI use, and it is the same code path a real session paints | |
| 134 | +/// through, not a second implementation of it. | |
| 135 | +#[no_mangle] | |
| 136 | +pub extern "C" fn tui_headless(width: c_int, height: c_int) -> c_int { | |
| 137 | + guard(0, || { | |
| 138 | + SESSION.with_borrow_mut(|slot| { | |
| 139 | + if slot.is_some() { | |
| 140 | + return 0; | |
| 141 | + } | |
| 142 | + *slot = Some(Session { | |
| 143 | + ui: Ui::new( | |
| 144 | + width.clamp(1, u16::MAX as c_int) as u16, | |
| 145 | + height.clamp(1, u16::MAX as c_int) as u16, | |
| 146 | + ), | |
| 147 | + #[cfg(feature = "terminal")] | |
| 148 | + term: None, | |
| 149 | + }); | |
| 150 | + 1 | |
| 151 | + }) | |
| 152 | + }) | |
| 153 | +} | |
| 154 | + | |
| 155 | +/// Give the terminal back and drop the tree. Safe to call twice. | |
| 156 | +#[no_mangle] | |
| 157 | +pub extern "C" fn tui_close() { | |
| 158 | + guard((), || { | |
| 159 | + SESSION.with_borrow_mut(|slot| { | |
| 160 | + #[cfg(feature = "terminal")] | |
| 161 | + if let Some(session) = slot.as_mut() { | |
| 162 | + if let Some(term) = session.term.as_mut() { | |
| 163 | + term.close(); | |
| 164 | + } | |
| 165 | + } | |
| 166 | + *slot = None; | |
| 167 | + }) | |
| 168 | + }) | |
| 169 | +} | |
| 170 | + | |
| 171 | +#[no_mangle] | |
| 172 | +pub extern "C" fn tui_should_close() -> c_int { | |
| 173 | + with_ui(1, |ui| ui.should_close() as c_int) | |
| 174 | +} | |
| 175 | + | |
| 176 | +#[no_mangle] | |
| 177 | +pub extern "C" fn tui_quit() { | |
| 178 | + with_ui((), |ui| ui.quit()) | |
| 179 | +} | |
| 180 | + | |
| 181 | +/// Wait up to `timeout_ms` for input, then handle everything that arrived. | |
| 182 | +/// Answers how many things it handled, so a caller can skip a repaint when | |
| 183 | +/// nothing happened. Inert in a headless session, which is fed by hand. | |
| 184 | +#[no_mangle] | |
| 185 | +pub extern "C" fn tui_tick(timeout_ms: c_int) -> c_int { | |
| 186 | + with(0, |session| { | |
| 187 | + #[cfg(feature = "terminal")] | |
| 188 | + { | |
| 189 | + let Some(term) = session.term.as_mut() else { | |
| 190 | + return 0; | |
| 191 | + }; | |
| 192 | + let inputs = term.poll(timeout_ms.max(0) as u64); | |
| 193 | + let mut handled = 0; | |
| 194 | + for input in inputs { | |
| 195 | + handled += 1; | |
| 196 | + match input { | |
| 197 | + term::Input::Key(name) => { | |
| 198 | + session.ui.key(&name); | |
| 199 | + } | |
| 200 | + term::Input::Click(x, y) => { | |
| 201 | + session.ui.click(x, y); | |
| 202 | + } | |
| 203 | + term::Input::Wheel(x, y, by) => { | |
| 204 | + session.ui.wheel(x, y, by); | |
| 205 | + } | |
| 206 | + term::Input::Resize(w, h) => session.ui.resize(w, h), | |
| 207 | + } | |
| 208 | + } | |
| 209 | + handled | |
| 210 | + } | |
| 211 | + #[cfg(not(feature = "terminal"))] | |
| 212 | + { | |
| 213 | + let _ = (session, timeout_ms); | |
| 214 | + 0 | |
| 215 | + } | |
| 216 | + }) | |
| 217 | +} | |
| 218 | + | |
| 219 | +/// Lay the tree out, paint it, and send what changed. A headless session paints | |
| 220 | +/// and stops there. | |
| 221 | +#[no_mangle] | |
| 222 | +pub extern "C" fn tui_frame() { | |
| 223 | + with((), |session| { | |
| 224 | + session.ui.frame(); | |
| 225 | + #[cfg(feature = "terminal")] | |
| 226 | + if let Some(term) = session.term.as_mut() { | |
| 227 | + let cursor = session.ui.cursor(); | |
| 228 | + if let Err(e) = term.flush(&session.ui.screen, cursor) { | |
| 229 | + log::error!("jolt-tui: could not write a frame: {e}"); | |
| 230 | + } | |
| 231 | + } | |
| 232 | + }) | |
| 233 | +} | |
| 234 | + | |
| 235 | +#[no_mangle] | |
| 236 | +pub extern "C" fn tui_screen_width() -> c_int { | |
| 237 | + with_ui(0, |ui| ui.screen.width() as c_int) | |
| 238 | +} | |
| 239 | + | |
| 240 | +#[no_mangle] | |
| 241 | +pub extern "C" fn tui_screen_height() -> c_int { | |
| 242 | + with_ui(0, |ui| ui.screen.height() as c_int) | |
| 243 | +} | |
| 244 | + | |
| 245 | +/// One painted row as text, trailing blanks trimmed — what a test asserts on, | |
| 246 | +/// and what a bug report pastes. Borrowed until the next read. | |
| 247 | +#[no_mangle] | |
| 248 | +pub extern "C" fn tui_screen_line(y: c_int) -> *const c_char { | |
| 249 | + with_ui(empty_str(), |ui| { | |
| 250 | + if y < 0 { | |
| 251 | + return empty_str(); | |
| 252 | + } | |
| 253 | + READS.lend(ui.screen.line(y as u16)) | |
| 254 | + }) | |
| 255 | +} | |
| 256 | + | |
| 257 | +// ── input by hand ─────────────────────────────────────────────────────────── | |
| 258 | + | |
| 259 | +/// Feed one key by name — `"ctrl+u"`, `"page-down"`, `"a"` — as if the terminal | |
| 260 | +/// had sent it. Answers 1 when the backend acted on it and 0 when it went out | |
| 261 | +/// as a `key` event instead. | |
| 262 | +/// | |
| 263 | +/// # Safety | |
| 264 | +/// `name` is null or a NUL-terminated UTF-8 string. | |
| 265 | +#[no_mangle] | |
| 266 | +pub unsafe extern "C" fn tui_feed_key(name: *const c_char) -> c_int { | |
| 267 | + let name = borrowed(name); | |
| 268 | + with_ui(0, |ui| ui.key(&name) as c_int) | |
| 269 | +} | |
| 270 | + | |
| 271 | +#[no_mangle] | |
| 272 | +pub extern "C" fn tui_feed_click(x: c_int, y: c_int) -> c_int { | |
| 273 | + with_ui(0, |ui| { | |
| 274 | + if x < 0 || y < 0 { | |
| 275 | + return 0; | |
| 276 | + } | |
| 277 | + ui.click(x as u16, y as u16) as c_int | |
| 278 | + }) | |
| 279 | +} | |
| 280 | + | |
| 281 | +#[no_mangle] | |
| 282 | +pub extern "C" fn tui_feed_wheel(x: c_int, y: c_int, by: c_int) -> c_int { | |
| 283 | + with_ui(0, |ui| { | |
| 284 | + if x < 0 || y < 0 { | |
| 285 | + return 0; | |
| 286 | + } | |
| 287 | + ui.wheel(x as u16, y as u16, by) as c_int | |
| 288 | + }) | |
| 289 | +} | |
| 290 | + | |
| 291 | +/// The focused node, 0 for none. | |
| 292 | +#[no_mangle] | |
| 293 | +pub extern "C" fn tui_focus() -> c_int { | |
| 294 | + with_ui(0, |ui| ui.focus() as c_int) | |
| 295 | +} | |
| 296 | + | |
| 297 | +// ── the tree ──────────────────────────────────────────────────────────────── | |
| 298 | + | |
| 299 | +#[no_mangle] | |
| 300 | +pub extern "C" fn tui_tree_root() -> c_int { | |
| 301 | + with_ui(0, |ui| ui.tree.root() as c_int) | |
| 302 | +} | |
| 303 | + | |
| 304 | +/// # Safety | |
| 305 | +/// `tag` is null or a NUL-terminated UTF-8 string. | |
| 306 | +#[no_mangle] | |
| 307 | +pub unsafe extern "C" fn tui_node_new(tag: *const c_char) -> c_int { | |
| 308 | + let tag = borrowed(tag); | |
| 309 | + with_ui(0, |ui| ui.tree.new_node(&tag) as c_int) | |
| 310 | +} | |
| 311 | + | |
| 312 | +#[no_mangle] | |
| 313 | +pub extern "C" fn tui_node_free(node: c_int) { | |
| 314 | + with_ui((), |ui| ui.tree.free_node(node.max(0) as u32)) | |
| 315 | +} | |
| 316 | + | |
| 317 | +#[no_mangle] | |
| 318 | +pub extern "C" fn tui_node_exists(node: c_int) -> c_int { | |
| 319 | + with_ui(0, |ui| ui.tree.exists(node.max(0) as u32) as c_int) | |
| 320 | +} | |
| 321 | + | |
| 322 | +/// # Safety | |
| 323 | +/// `key` and `value` are null or NUL-terminated UTF-8 strings. | |
| 324 | +#[no_mangle] | |
| 325 | +pub unsafe extern "C" fn tui_node_set_str(node: c_int, key: *const c_char, value: *const c_char) { | |
| 326 | + let (key, value) = (borrowed(key), borrowed(value)); | |
| 327 | + with_ui((), |ui| { | |
| 328 | + ui.tree.set(node.max(0) as u32, &key, Value::Str(value)) | |
| 329 | + }) | |
| 330 | +} | |
| 331 | + | |
| 332 | +/// # Safety | |
| 333 | +/// `key` is null or a NUL-terminated UTF-8 string. | |
| 334 | +#[no_mangle] | |
| 335 | +pub unsafe extern "C" fn tui_node_set_num(node: c_int, key: *const c_char, value: c_double) { | |
| 336 | + let key = borrowed(key); | |
| 337 | + with_ui((), |ui| { | |
| 338 | + ui.tree.set(node.max(0) as u32, &key, Value::Num(value)) | |
| 339 | + }) | |
| 340 | +} | |
| 341 | + | |
| 342 | +/// # Safety | |
| 343 | +/// `key` is null or a NUL-terminated UTF-8 string. | |
| 344 | +#[no_mangle] | |
| 345 | +pub unsafe extern "C" fn tui_node_set_bool(node: c_int, key: *const c_char, value: c_int) { | |
| 346 | + let key = borrowed(key); | |
| 347 | + with_ui((), |ui| { | |
| 348 | + ui.tree | |
| 349 | + .set(node.max(0) as u32, &key, Value::Bool(value != 0)) | |
| 350 | + }) | |
| 351 | +} | |
| 352 | + | |
| 353 | +#[no_mangle] | |
| 354 | +pub extern "C" fn tui_node_clear_props(node: c_int) { | |
| 355 | + with_ui((), |ui| ui.tree.clear_props(node.max(0) as u32)) | |
| 356 | +} | |
| 357 | + | |
| 358 | +/// # Safety | |
| 359 | +/// `key` is null or a NUL-terminated UTF-8 string. | |
| 360 | +#[no_mangle] | |
| 361 | +pub unsafe extern "C" fn tui_node_get_str(node: c_int, key: *const c_char) -> *const c_char { | |
| 362 | + let key = borrowed(key); | |
| 363 | + with_ui(empty_str(), |ui| { | |
| 364 | + match ui.tree.get(node.max(0) as u32, &key) { | |
| 365 | + Some(Value::Str(text)) => READS.lend(text.clone()), | |
| 366 | + _ => empty_str(), | |
| 367 | + } | |
| 368 | + }) | |
| 369 | +} | |
| 370 | + | |
| 371 | +/// # Safety | |
| 372 | +/// `key` is null or a NUL-terminated UTF-8 string. | |
| 373 | +#[no_mangle] | |
| 374 | +pub unsafe extern "C" fn tui_node_get_num(node: c_int, key: *const c_char) -> c_double { | |
| 375 | + let key = borrowed(key); | |
| 376 | + with_ui(0.0, |ui| match ui.tree.get(node.max(0) as u32, &key) { | |
| 377 | + Some(Value::Num(n)) => *n, | |
| 378 | + Some(Value::Bool(b)) => *b as i32 as f64, | |
| 379 | + _ => 0.0, | |
| 380 | + }) | |
| 381 | +} | |
| 382 | + | |
| 383 | +/// # Safety | |
| 384 | +/// `key` is null or a NUL-terminated UTF-8 string. | |
| 385 | +#[no_mangle] | |
| 386 | +pub unsafe extern "C" fn tui_node_get_bool(node: c_int, key: *const c_char) -> c_int { | |
| 387 | + let key = borrowed(key); | |
| 388 | + with_ui(0, |ui| match ui.tree.get(node.max(0) as u32, &key) { | |
| 389 | + Some(Value::Bool(b)) => *b as c_int, | |
| 390 | + Some(Value::Num(n)) => (*n != 0.0) as c_int, | |
| 391 | + _ => 0, | |
| 392 | + }) | |
| 393 | +} | |
| 394 | + | |
| 395 | +#[no_mangle] | |
| 396 | +pub extern "C" fn tui_node_tag(node: c_int) -> *const c_char { | |
| 397 | + with_ui(empty_str(), |ui| { | |
| 398 | + READS.lend(ui.tree.tag_name(node.max(0) as u32).to_owned()) | |
| 399 | + }) | |
| 400 | +} | |
| 401 | + | |
| 402 | +/// The node this one hangs off, 0 when it is unparented or is the window. | |
| 403 | +#[no_mangle] | |
| 404 | +pub extern "C" fn tui_node_parent(node: c_int) -> c_int { | |
| 405 | + with_ui(0, |ui| ui.tree.parent(node.max(0) as u32) as c_int) | |
| 406 | +} | |
| 407 | + | |
| 408 | +#[no_mangle] | |
| 409 | +pub extern "C" fn tui_node_child_count(node: c_int) -> c_int { | |
| 410 | + with_ui(0, |ui| ui.tree.child_count(node.max(0) as u32) as c_int) | |
| 411 | +} | |
| 412 | + | |
| 413 | +#[no_mangle] | |
| 414 | +pub extern "C" fn tui_node_child_at(node: c_int, index: c_int) -> c_int { | |
| 415 | + with_ui(0, |ui| { | |
| 416 | + if index < 0 { | |
| 417 | + return 0; | |
| 418 | + } | |
| 419 | + ui.tree.child_at(node.max(0) as u32, index as usize) as c_int | |
| 420 | + }) | |
| 421 | +} | |
| 422 | + | |
| 423 | +#[no_mangle] | |
| 424 | +pub extern "C" fn tui_node_append(parent: c_int, child: c_int) -> c_int { | |
| 425 | + with_ui(0, |ui| { | |
| 426 | + ui.tree.append(parent.max(0) as u32, child.max(0) as u32) as c_int | |
| 427 | + }) | |
| 428 | +} | |
| 429 | + | |
| 430 | +#[no_mangle] | |
| 431 | +pub extern "C" fn tui_node_remove(parent: c_int, child: c_int) { | |
| 432 | + with_ui((), |ui| { | |
| 433 | + ui.tree.remove(parent.max(0) as u32, child.max(0) as u32) | |
| 434 | + }) | |
| 435 | +} | |
| 436 | + | |
| 437 | +#[no_mangle] | |
| 438 | +pub extern "C" fn tui_node_insert_after(parent: c_int, child: c_int, sibling: c_int) -> c_int { | |
| 439 | + with_ui(0, |ui| { | |
| 440 | + ui.tree.insert_after( | |
| 441 | + parent.max(0) as u32, | |
| 442 | + child.max(0) as u32, | |
| 443 | + sibling.max(0) as u32, | |
| 444 | + ) as c_int | |
| 445 | + }) | |
| 446 | +} | |
| 447 | + | |
| 448 | +#[no_mangle] | |
| 449 | +pub extern "C" fn tui_node_replace(parent: c_int, old_child: c_int, new_child: c_int) -> c_int { | |
| 450 | + with_ui(0, |ui| { | |
| 451 | + ui.tree.replace( | |
| 452 | + parent.max(0) as u32, | |
| 453 | + old_child.max(0) as u32, | |
| 454 | + new_child.max(0) as u32, | |
| 455 | + ) as c_int | |
| 456 | + }) | |
| 457 | +} | |
| 458 | + | |
| 459 | +/// The subtree at `node` as pretty-printed hiccup; `node` 0 means the root, so | |
| 460 | +/// `tui_tree_dump(0)` is the whole window. | |
| 461 | +#[no_mangle] | |
| 462 | +pub extern "C" fn tui_tree_dump(node: c_int) -> *const c_char { | |
| 463 | + with_ui(empty_str(), |ui| { | |
| 464 | + let id = if node <= 0 { | |
| 465 | + ui.tree.root() | |
| 466 | + } else { | |
| 467 | + node as u32 | |
| 468 | + }; | |
| 469 | + DUMPS.lend(ui.tree.dump(id)) | |
| 470 | + }) | |
| 471 | +} | |
| 472 | + | |
| 473 | +// ── events ────────────────────────────────────────────────────────────────── | |
| 474 | + | |
| 475 | +#[no_mangle] | |
| 476 | +pub extern "C" fn tui_tree_poll_event() -> c_int { | |
| 477 | + with_ui(0, |ui| ui.tree.poll() as c_int) | |
| 478 | +} | |
| 479 | + | |
| 480 | +#[no_mangle] | |
| 481 | +pub extern "C" fn tui_tree_event_node() -> c_int { | |
| 482 | + with_ui(0, |ui| { | |
| 483 | + ui.tree.current().map_or(0, |event| event.node as c_int) | |
| 484 | + }) | |
| 485 | +} | |
| 486 | + | |
| 487 | +#[no_mangle] | |
| 488 | +pub extern "C" fn tui_tree_event_name() -> *const c_char { | |
| 489 | + with_ui(empty_str(), |ui| match ui.tree.current() { | |
| 490 | + Some(event) => NAMES.lend(event.name), | |
| 491 | + None => empty_str(), | |
| 492 | + }) | |
| 493 | +} | |
| 494 | + | |
| 495 | +#[no_mangle] | |
| 496 | +pub extern "C" fn tui_tree_event_text() -> *const c_char { | |
| 497 | + with_ui(empty_str(), |ui| match ui.tree.current() { | |
| 498 | + Some(event) => EVENTS.lend(event.text.clone()), | |
| 499 | + None => empty_str(), | |
| 500 | + }) | |
| 501 | +} | |
| 502 | + | |
| 503 | +#[no_mangle] | |
| 504 | +pub extern "C" fn tui_tree_event_num() -> c_double { | |
| 505 | + with_ui(0.0, |ui| ui.tree.current().map_or(0.0, |event| event.num)) | |
| 506 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,506 @@ | |||
| 1 | +//! A terminal backend for glimmer, behind a C ABI — `libjolttui.so`. | ||
| 2 | +//! | ||
| 3 | +//! [`glimmer-tui`](https://github.com/jolt-lang/glimmer-tui) is the design this | ||
| 4 | +//! follows: the same tags, the same props, the same keyboard, and the same rule | ||
| 5 | +//! that painting goes through a grid so a test needs no terminal. What is | ||
| 6 | +//! different is where the widget layer lives. There it is jolt over ncurses; | ||
| 7 | +//! here it is Rust behind the same retained-tree ABI `libvidya` already | ||
| 8 | +//! exports, so one glimmer backend on the jolt side can drive a GPU window or a | ||
| 9 | +//! terminal by naming a different shared object. | ||
| 10 | +//! | ||
| 11 | +//! That split is the point. A reconciler needs widgets to patch, and a terminal | ||
| 12 | +//! has none — so the tree lives down here, and FFI traffic tracks *edits* | ||
| 13 | +//! rather than frames: a static screen costs no crossings per frame, and only | ||
| 14 | +//! what the reconciler actually changed is sent. | ||
| 15 | +//! | ||
| 16 | +//! Rules inherited from this workspace's ABI: | ||
| 17 | +//! | ||
| 18 | +//! * one session per process; | ||
| 19 | +//! * every call stays on the thread that opened it — the session lives in | ||
| 20 | +//! thread-local storage, so a call from another thread is inert rather than | ||
| 21 | +//! unsound; | ||
| 22 | +//! * only integers, doubles, and UTF-8 byte strings cross, and a returned | ||
| 23 | +//! string is borrowed until the next one of its family; | ||
| 24 | +//! * nothing calls back. Interactions queue, and the caller polls. | ||
| 25 | +//! | ||
| 26 | +//! Handlers never cross the boundary: a node reports that it was clicked, and | ||
| 27 | +//! the caller looks up whose `:on-click` that was. | ||
| 28 | + | ||
| 29 | +// Without the terminal feature the flush path is gone, and with it the only | ||
| 30 | +// caller of a handful of screen and session accessors. They are the ABI's | ||
| 31 | +// vocabulary, not dead code, so a headless build does not warn about them. | ||
| 32 | +#![cfg_attr(not(feature = "terminal"), allow(dead_code))] | ||
| 33 | + | ||
| 34 | +mod keys; | ||
| 35 | +mod layout; | ||
| 36 | +mod paint; | ||
| 37 | +mod screen; | ||
| 38 | +#[cfg(feature = "terminal")] | ||
| 39 | +mod term; | ||
| 40 | +mod tree; | ||
| 41 | +mod ui; | ||
| 42 | + | ||
| 43 | +#[cfg(test)] | ||
| 44 | +mod tests; | ||
| 45 | + | ||
| 46 | +use std::cell::RefCell; | ||
| 47 | +use std::ffi::{c_char, c_double, c_int}; | ||
| 48 | + | ||
| 49 | +use jolt_abi::{borrowed, empty_str, guard, Scratch}; | ||
| 50 | +use tree::Value; | ||
| 51 | +use ui::Ui; | ||
| 52 | + | ||
| 53 | +/// The reads: a prop, a tag, a line of the screen. One scratch, so a caller | ||
| 54 | +/// holding a tag pointer across a prop read gets the documented lifetime and | ||
| 55 | +/// not a surprise. | ||
| 56 | +static READS: Scratch = Scratch::new(); | ||
| 57 | +/// Dumps are their own family: a dump is usually being printed beside the | ||
| 58 | +/// props it mentions. | ||
| 59 | +static DUMPS: Scratch = Scratch::new(); | ||
| 60 | +/// The event name and the event text are read one after the other by every | ||
| 61 | +/// caller there will ever be, so they cannot share a scratch. | ||
| 62 | +static NAMES: Scratch = Scratch::new(); | ||
| 63 | +static EVENTS: Scratch = Scratch::new(); | ||
| 64 | + | ||
| 65 | +struct Session { | ||
| 66 | + ui: Ui, | ||
| 67 | + #[cfg(feature = "terminal")] | ||
| 68 | + term: Option<term::Term>, | ||
| 69 | +} | ||
| 70 | + | ||
| 71 | +thread_local! { | ||
| 72 | + /// The process's session, owned by the thread that opened it. | ||
| 73 | + static SESSION: RefCell<Option<Session>> = const { RefCell::new(None) }; | ||
| 74 | +} | ||
| 75 | + | ||
| 76 | +fn with<R: Copy>(fallback: R, f: impl FnOnce(&mut Session) -> R) -> R { | ||
| 77 | + guard(fallback, || { | ||
| 78 | + SESSION.with_borrow_mut(|slot| match slot.as_mut() { | ||
| 79 | + Some(session) => f(session), | ||
| 80 | + None => fallback, | ||
| 81 | + }) | ||
| 82 | + }) | ||
| 83 | +} | ||
| 84 | + | ||
| 85 | +/// Most of this ABI is a call on the tree with a session around it. | ||
| 86 | +fn with_ui<R: Copy>(fallback: R, f: impl FnOnce(&mut Ui) -> R) -> R { | ||
| 87 | + with(fallback, |session| f(&mut session.ui)) | ||
| 88 | +} | ||
| 89 | + | ||
| 90 | +// ── the session ───────────────────────────────────────────────────────────── | ||
| 91 | + | ||
| 92 | +/// Take the terminal. `mouse` non-zero turns on mouse reporting. 1 on success, | ||
| 93 | +/// 0 if a session is already open or the terminal refused raw mode. | ||
| 94 | +#[no_mangle] | ||
| 95 | +pub extern "C" fn tui_open(mouse: c_int) -> c_int { | ||
| 96 | + guard(0, || { | ||
| 97 | + SESSION.with_borrow_mut(|slot| { | ||
| 98 | + if slot.is_some() { | ||
| 99 | + log::error!("jolt-tui: a session is already open"); | ||
| 100 | + return 0; | ||
| 101 | + } | ||
| 102 | + #[cfg(feature = "terminal")] | ||
| 103 | + { | ||
| 104 | + match term::Term::open(mouse != 0) { | ||
| 105 | + Ok(term) => { | ||
| 106 | + let (w, h) = term.size(); | ||
| 107 | + *slot = Some(Session { | ||
| 108 | + ui: Ui::new(w, h), | ||
| 109 | + term: Some(term), | ||
| 110 | + }); | ||
| 111 | + 1 | ||
| 112 | + } | ||
| 113 | + Err(e) => { | ||
| 114 | + log::error!("jolt-tui: could not take the terminal: {e}"); | ||
| 115 | + 0 | ||
| 116 | + } | ||
| 117 | + } | ||
| 118 | + } | ||
| 119 | + #[cfg(not(feature = "terminal"))] | ||
| 120 | + { | ||
| 121 | + let _ = mouse; | ||
| 122 | + log::error!("jolt-tui: built without the terminal feature"); | ||
| 123 | + 0 | ||
| 124 | + } | ||
| 125 | + }) | ||
| 126 | + }) | ||
| 127 | +} | ||
| 128 | + | ||
| 129 | +/// Open a session with no terminal at all, at a fixed size. | ||
| 130 | +/// | ||
| 131 | +/// The whole widget layer works here — layout, painting, focus, keys fed with | ||
| 132 | +/// `tui_feed_key` — and `tui_screen_line` reads the result back. This is what a | ||
| 133 | +/// test suite and CI use, and it is the same code path a real session paints | ||
| 134 | +/// through, not a second implementation of it. | ||
| 135 | +#[no_mangle] | ||
| 136 | +pub extern "C" fn tui_headless(width: c_int, height: c_int) -> c_int { | ||
| 137 | + guard(0, || { | ||
| 138 | + SESSION.with_borrow_mut(|slot| { | ||
| 139 | + if slot.is_some() { | ||
| 140 | + return 0; | ||
| 141 | + } | ||
| 142 | + *slot = Some(Session { | ||
| 143 | + ui: Ui::new( | ||
| 144 | + width.clamp(1, u16::MAX as c_int) as u16, | ||
| 145 | + height.clamp(1, u16::MAX as c_int) as u16, | ||
| 146 | + ), | ||
| 147 | + #[cfg(feature = "terminal")] | ||
| 148 | + term: None, | ||
| 149 | + }); | ||
| 150 | + 1 | ||
| 151 | + }) | ||
| 152 | + }) | ||
| 153 | +} | ||
| 154 | + | ||
| 155 | +/// Give the terminal back and drop the tree. Safe to call twice. | ||
| 156 | +#[no_mangle] | ||
| 157 | +pub extern "C" fn tui_close() { | ||
| 158 | + guard((), || { | ||
| 159 | + SESSION.with_borrow_mut(|slot| { | ||
| 160 | + #[cfg(feature = "terminal")] | ||
| 161 | + if let Some(session) = slot.as_mut() { | ||
| 162 | + if let Some(term) = session.term.as_mut() { | ||
| 163 | + term.close(); | ||
| 164 | + } | ||
| 165 | + } | ||
| 166 | + *slot = None; | ||
| 167 | + }) | ||
| 168 | + }) | ||
| 169 | +} | ||
| 170 | + | ||
| 171 | +#[no_mangle] | ||
| 172 | +pub extern "C" fn tui_should_close() -> c_int { | ||
| 173 | + with_ui(1, |ui| ui.should_close() as c_int) | ||
| 174 | +} | ||
| 175 | + | ||
| 176 | +#[no_mangle] | ||
| 177 | +pub extern "C" fn tui_quit() { | ||
| 178 | + with_ui((), |ui| ui.quit()) | ||
| 179 | +} | ||
| 180 | + | ||
| 181 | +/// Wait up to `timeout_ms` for input, then handle everything that arrived. | ||
| 182 | +/// Answers how many things it handled, so a caller can skip a repaint when | ||
| 183 | +/// nothing happened. Inert in a headless session, which is fed by hand. | ||
| 184 | +#[no_mangle] | ||
| 185 | +pub extern "C" fn tui_tick(timeout_ms: c_int) -> c_int { | ||
| 186 | + with(0, |session| { | ||
| 187 | + #[cfg(feature = "terminal")] | ||
| 188 | + { | ||
| 189 | + let Some(term) = session.term.as_mut() else { | ||
| 190 | + return 0; | ||
| 191 | + }; | ||
| 192 | + let inputs = term.poll(timeout_ms.max(0) as u64); | ||
| 193 | + let mut handled = 0; | ||
| 194 | + for input in inputs { | ||
| 195 | + handled += 1; | ||
| 196 | + match input { | ||
| 197 | + term::Input::Key(name) => { | ||
| 198 | + session.ui.key(&name); | ||
| 199 | + } | ||
| 200 | + term::Input::Click(x, y) => { | ||
| 201 | + session.ui.click(x, y); | ||
| 202 | + } | ||
| 203 | + term::Input::Wheel(x, y, by) => { | ||
| 204 | + session.ui.wheel(x, y, by); | ||
| 205 | + } | ||
| 206 | + term::Input::Resize(w, h) => session.ui.resize(w, h), | ||
| 207 | + } | ||
| 208 | + } | ||
| 209 | + handled | ||
| 210 | + } | ||
| 211 | + #[cfg(not(feature = "terminal"))] | ||
| 212 | + { | ||
| 213 | + let _ = (session, timeout_ms); | ||
| 214 | + 0 | ||
| 215 | + } | ||
| 216 | + }) | ||
| 217 | +} | ||
| 218 | + | ||
| 219 | +/// Lay the tree out, paint it, and send what changed. A headless session paints | ||
| 220 | +/// and stops there. | ||
| 221 | +#[no_mangle] | ||
| 222 | +pub extern "C" fn tui_frame() { | ||
| 223 | + with((), |session| { | ||
| 224 | + session.ui.frame(); | ||
| 225 | + #[cfg(feature = "terminal")] | ||
| 226 | + if let Some(term) = session.term.as_mut() { | ||
| 227 | + let cursor = session.ui.cursor(); | ||
| 228 | + if let Err(e) = term.flush(&session.ui.screen, cursor) { | ||
| 229 | + log::error!("jolt-tui: could not write a frame: {e}"); | ||
| 230 | + } | ||
| 231 | + } | ||
| 232 | + }) | ||
| 233 | +} | ||
| 234 | + | ||
| 235 | +#[no_mangle] | ||
| 236 | +pub extern "C" fn tui_screen_width() -> c_int { | ||
| 237 | + with_ui(0, |ui| ui.screen.width() as c_int) | ||
| 238 | +} | ||
| 239 | + | ||
| 240 | +#[no_mangle] | ||
| 241 | +pub extern "C" fn tui_screen_height() -> c_int { | ||
| 242 | + with_ui(0, |ui| ui.screen.height() as c_int) | ||
| 243 | +} | ||
| 244 | + | ||
| 245 | +/// One painted row as text, trailing blanks trimmed — what a test asserts on, | ||
| 246 | +/// and what a bug report pastes. Borrowed until the next read. | ||
| 247 | +#[no_mangle] | ||
| 248 | +pub extern "C" fn tui_screen_line(y: c_int) -> *const c_char { | ||
| 249 | + with_ui(empty_str(), |ui| { | ||
| 250 | + if y < 0 { | ||
| 251 | + return empty_str(); | ||
| 252 | + } | ||
| 253 | + READS.lend(ui.screen.line(y as u16)) | ||
| 254 | + }) | ||
| 255 | +} | ||
| 256 | + | ||
| 257 | +// ── input by hand ─────────────────────────────────────────────────────────── | ||
| 258 | + | ||
| 259 | +/// Feed one key by name — `"ctrl+u"`, `"page-down"`, `"a"` — as if the terminal | ||
| 260 | +/// had sent it. Answers 1 when the backend acted on it and 0 when it went out | ||
| 261 | +/// as a `key` event instead. | ||
| 262 | +/// | ||
| 263 | +/// # Safety | ||
| 264 | +/// `name` is null or a NUL-terminated UTF-8 string. | ||
| 265 | +#[no_mangle] | ||
| 266 | +pub unsafe extern "C" fn tui_feed_key(name: *const c_char) -> c_int { | ||
| 267 | + let name = borrowed(name); | ||
| 268 | + with_ui(0, |ui| ui.key(&name) as c_int) | ||
| 269 | +} | ||
| 270 | + | ||
| 271 | +#[no_mangle] | ||
| 272 | +pub extern "C" fn tui_feed_click(x: c_int, y: c_int) -> c_int { | ||
| 273 | + with_ui(0, |ui| { | ||
| 274 | + if x < 0 || y < 0 { | ||
| 275 | + return 0; | ||
| 276 | + } | ||
| 277 | + ui.click(x as u16, y as u16) as c_int | ||
| 278 | + }) | ||
| 279 | +} | ||
| 280 | + | ||
| 281 | +#[no_mangle] | ||
| 282 | +pub extern "C" fn tui_feed_wheel(x: c_int, y: c_int, by: c_int) -> c_int { | ||
| 283 | + with_ui(0, |ui| { | ||
| 284 | + if x < 0 || y < 0 { | ||
| 285 | + return 0; | ||
| 286 | + } | ||
| 287 | + ui.wheel(x as u16, y as u16, by) as c_int | ||
| 288 | + }) | ||
| 289 | +} | ||
| 290 | + | ||
| 291 | +/// The focused node, 0 for none. | ||
| 292 | +#[no_mangle] | ||
| 293 | +pub extern "C" fn tui_focus() -> c_int { | ||
| 294 | + with_ui(0, |ui| ui.focus() as c_int) | ||
| 295 | +} | ||
| 296 | + | ||
| 297 | +// ── the tree ──────────────────────────────────────────────────────────────── | ||
| 298 | + | ||
| 299 | +#[no_mangle] | ||
| 300 | +pub extern "C" fn tui_tree_root() -> c_int { | ||
| 301 | + with_ui(0, |ui| ui.tree.root() as c_int) | ||
| 302 | +} | ||
| 303 | + | ||
| 304 | +/// # Safety | ||
| 305 | +/// `tag` is null or a NUL-terminated UTF-8 string. | ||
| 306 | +#[no_mangle] | ||
| 307 | +pub unsafe extern "C" fn tui_node_new(tag: *const c_char) -> c_int { | ||
| 308 | + let tag = borrowed(tag); | ||
| 309 | + with_ui(0, |ui| ui.tree.new_node(&tag) as c_int) | ||
| 310 | +} | ||
| 311 | + | ||
| 312 | +#[no_mangle] | ||
| 313 | +pub extern "C" fn tui_node_free(node: c_int) { | ||
| 314 | + with_ui((), |ui| ui.tree.free_node(node.max(0) as u32)) | ||
| 315 | +} | ||
| 316 | + | ||
| 317 | +#[no_mangle] | ||
| 318 | +pub extern "C" fn tui_node_exists(node: c_int) -> c_int { | ||
| 319 | + with_ui(0, |ui| ui.tree.exists(node.max(0) as u32) as c_int) | ||
| 320 | +} | ||
| 321 | + | ||
| 322 | +/// # Safety | ||
| 323 | +/// `key` and `value` are null or NUL-terminated UTF-8 strings. | ||
| 324 | +#[no_mangle] | ||
| 325 | +pub unsafe extern "C" fn tui_node_set_str(node: c_int, key: *const c_char, value: *const c_char) { | ||
| 326 | + let (key, value) = (borrowed(key), borrowed(value)); | ||
| 327 | + with_ui((), |ui| { | ||
| 328 | + ui.tree.set(node.max(0) as u32, &key, Value::Str(value)) | ||
| 329 | + }) | ||
| 330 | +} | ||
| 331 | + | ||
| 332 | +/// # Safety | ||
| 333 | +/// `key` is null or a NUL-terminated UTF-8 string. | ||
| 334 | +#[no_mangle] | ||
| 335 | +pub unsafe extern "C" fn tui_node_set_num(node: c_int, key: *const c_char, value: c_double) { | ||
| 336 | + let key = borrowed(key); | ||
| 337 | + with_ui((), |ui| { | ||
| 338 | + ui.tree.set(node.max(0) as u32, &key, Value::Num(value)) | ||
| 339 | + }) | ||
| 340 | +} | ||
| 341 | + | ||
| 342 | +/// # Safety | ||
| 343 | +/// `key` is null or a NUL-terminated UTF-8 string. | ||
| 344 | +#[no_mangle] | ||
| 345 | +pub unsafe extern "C" fn tui_node_set_bool(node: c_int, key: *const c_char, value: c_int) { | ||
| 346 | + let key = borrowed(key); | ||
| 347 | + with_ui((), |ui| { | ||
| 348 | + ui.tree | ||
| 349 | + .set(node.max(0) as u32, &key, Value::Bool(value != 0)) | ||
| 350 | + }) | ||
| 351 | +} | ||
| 352 | + | ||
| 353 | +#[no_mangle] | ||
| 354 | +pub extern "C" fn tui_node_clear_props(node: c_int) { | ||
| 355 | + with_ui((), |ui| ui.tree.clear_props(node.max(0) as u32)) | ||
| 356 | +} | ||
| 357 | + | ||
| 358 | +/// # Safety | ||
| 359 | +/// `key` is null or a NUL-terminated UTF-8 string. | ||
| 360 | +#[no_mangle] | ||
| 361 | +pub unsafe extern "C" fn tui_node_get_str(node: c_int, key: *const c_char) -> *const c_char { | ||
| 362 | + let key = borrowed(key); | ||
| 363 | + with_ui(empty_str(), |ui| { | ||
| 364 | + match ui.tree.get(node.max(0) as u32, &key) { | ||
| 365 | + Some(Value::Str(text)) => READS.lend(text.clone()), | ||
| 366 | + _ => empty_str(), | ||
| 367 | + } | ||
| 368 | + }) | ||
| 369 | +} | ||
| 370 | + | ||
| 371 | +/// # Safety | ||
| 372 | +/// `key` is null or a NUL-terminated UTF-8 string. | ||
| 373 | +#[no_mangle] | ||
| 374 | +pub unsafe extern "C" fn tui_node_get_num(node: c_int, key: *const c_char) -> c_double { | ||
| 375 | + let key = borrowed(key); | ||
| 376 | + with_ui(0.0, |ui| match ui.tree.get(node.max(0) as u32, &key) { | ||
| 377 | + Some(Value::Num(n)) => *n, | ||
| 378 | + Some(Value::Bool(b)) => *b as i32 as f64, | ||
| 379 | + _ => 0.0, | ||
| 380 | + }) | ||
| 381 | +} | ||
| 382 | + | ||
| 383 | +/// # Safety | ||
| 384 | +/// `key` is null or a NUL-terminated UTF-8 string. | ||
| 385 | +#[no_mangle] | ||
| 386 | +pub unsafe extern "C" fn tui_node_get_bool(node: c_int, key: *const c_char) -> c_int { | ||
| 387 | + let key = borrowed(key); | ||
| 388 | + with_ui(0, |ui| match ui.tree.get(node.max(0) as u32, &key) { | ||
| 389 | + Some(Value::Bool(b)) => *b as c_int, | ||
| 390 | + Some(Value::Num(n)) => (*n != 0.0) as c_int, | ||
| 391 | + _ => 0, | ||
| 392 | + }) | ||
| 393 | +} | ||
| 394 | + | ||
| 395 | +#[no_mangle] | ||
| 396 | +pub extern "C" fn tui_node_tag(node: c_int) -> *const c_char { | ||
| 397 | + with_ui(empty_str(), |ui| { | ||
| 398 | + READS.lend(ui.tree.tag_name(node.max(0) as u32).to_owned()) | ||
| 399 | + }) | ||
| 400 | +} | ||
| 401 | + | ||
| 402 | +/// The node this one hangs off, 0 when it is unparented or is the window. | ||
| 403 | +#[no_mangle] | ||
| 404 | +pub extern "C" fn tui_node_parent(node: c_int) -> c_int { | ||
| 405 | + with_ui(0, |ui| ui.tree.parent(node.max(0) as u32) as c_int) | ||
| 406 | +} | ||
| 407 | + | ||
| 408 | +#[no_mangle] | ||
| 409 | +pub extern "C" fn tui_node_child_count(node: c_int) -> c_int { | ||
| 410 | + with_ui(0, |ui| ui.tree.child_count(node.max(0) as u32) as c_int) | ||
| 411 | +} | ||
| 412 | + | ||
| 413 | +#[no_mangle] | ||
| 414 | +pub extern "C" fn tui_node_child_at(node: c_int, index: c_int) -> c_int { | ||
| 415 | + with_ui(0, |ui| { | ||
| 416 | + if index < 0 { | ||
| 417 | + return 0; | ||
| 418 | + } | ||
| 419 | + ui.tree.child_at(node.max(0) as u32, index as usize) as c_int | ||
| 420 | + }) | ||
| 421 | +} | ||
| 422 | + | ||
| 423 | +#[no_mangle] | ||
| 424 | +pub extern "C" fn tui_node_append(parent: c_int, child: c_int) -> c_int { | ||
| 425 | + with_ui(0, |ui| { | ||
| 426 | + ui.tree.append(parent.max(0) as u32, child.max(0) as u32) as c_int | ||
| 427 | + }) | ||
| 428 | +} | ||
| 429 | + | ||
| 430 | +#[no_mangle] | ||
| 431 | +pub extern "C" fn tui_node_remove(parent: c_int, child: c_int) { | ||
| 432 | + with_ui((), |ui| { | ||
| 433 | + ui.tree.remove(parent.max(0) as u32, child.max(0) as u32) | ||
| 434 | + }) | ||
| 435 | +} | ||
| 436 | + | ||
| 437 | +#[no_mangle] | ||
| 438 | +pub extern "C" fn tui_node_insert_after(parent: c_int, child: c_int, sibling: c_int) -> c_int { | ||
| 439 | + with_ui(0, |ui| { | ||
| 440 | + ui.tree.insert_after( | ||
| 441 | + parent.max(0) as u32, | ||
| 442 | + child.max(0) as u32, | ||
| 443 | + sibling.max(0) as u32, | ||
| 444 | + ) as c_int | ||
| 445 | + }) | ||
| 446 | +} | ||
| 447 | + | ||
| 448 | +#[no_mangle] | ||
| 449 | +pub extern "C" fn tui_node_replace(parent: c_int, old_child: c_int, new_child: c_int) -> c_int { | ||
| 450 | + with_ui(0, |ui| { | ||
| 451 | + ui.tree.replace( | ||
| 452 | + parent.max(0) as u32, | ||
| 453 | + old_child.max(0) as u32, | ||
| 454 | + new_child.max(0) as u32, | ||
| 455 | + ) as c_int | ||
| 456 | + }) | ||
| 457 | +} | ||
| 458 | + | ||
| 459 | +/// The subtree at `node` as pretty-printed hiccup; `node` 0 means the root, so | ||
| 460 | +/// `tui_tree_dump(0)` is the whole window. | ||
| 461 | +#[no_mangle] | ||
| 462 | +pub extern "C" fn tui_tree_dump(node: c_int) -> *const c_char { | ||
| 463 | + with_ui(empty_str(), |ui| { | ||
| 464 | + let id = if node <= 0 { | ||
| 465 | + ui.tree.root() | ||
| 466 | + } else { | ||
| 467 | + node as u32 | ||
| 468 | + }; | ||
| 469 | + DUMPS.lend(ui.tree.dump(id)) | ||
| 470 | + }) | ||
| 471 | +} | ||
| 472 | + | ||
| 473 | +// ── events ────────────────────────────────────────────────────────────────── | ||
| 474 | + | ||
| 475 | +#[no_mangle] | ||
| 476 | +pub extern "C" fn tui_tree_poll_event() -> c_int { | ||
| 477 | + with_ui(0, |ui| ui.tree.poll() as c_int) | ||
| 478 | +} | ||
| 479 | + | ||
| 480 | +#[no_mangle] | ||
| 481 | +pub extern "C" fn tui_tree_event_node() -> c_int { | ||
| 482 | + with_ui(0, |ui| { | ||
| 483 | + ui.tree.current().map_or(0, |event| event.node as c_int) | ||
| 484 | + }) | ||
| 485 | +} | ||
| 486 | + | ||
| 487 | +#[no_mangle] | ||
| 488 | +pub extern "C" fn tui_tree_event_name() -> *const c_char { | ||
| 489 | + with_ui(empty_str(), |ui| match ui.tree.current() { | ||
| 490 | + Some(event) => NAMES.lend(event.name), | ||
| 491 | + None => empty_str(), | ||
| 492 | + }) | ||
| 493 | +} | ||
| 494 | + | ||
| 495 | +#[no_mangle] | ||
| 496 | +pub extern "C" fn tui_tree_event_text() -> *const c_char { | ||
| 497 | + with_ui(empty_str(), |ui| match ui.tree.current() { | ||
| 498 | + Some(event) => EVENTS.lend(event.text.clone()), | ||
| 499 | + None => empty_str(), | ||
| 500 | + }) | ||
| 501 | +} | ||
| 502 | + | ||
| 503 | +#[no_mangle] | ||
| 504 | +pub extern "C" fn tui_tree_event_num() -> c_double { | ||
| 505 | + with_ui(0.0, |ui| ui.tree.current().map_or(0.0, |event| event.num)) | ||
| 506 | +} | ||
added
crates/jolt-tui/src/paint.rs +438 -0 | new file mode 100644 | ||
| @@ -0,0 +1,438 @@ | ||
| 1 | +//! Drawing the tree into a grid of cells. | |
| 2 | +//! | |
| 3 | +//! One pass, top to bottom: each node is handed a rect by [`crate::layout`] and | |
| 4 | +//! paints itself into it. Two things fall out of the walk and are kept — | |
| 5 | +//! the focus ring, in the order the widgets were painted, and every focusable | |
| 6 | +//! widget's rect, so a mouse click can be turned back into a node. | |
| 7 | +//! | |
| 8 | +//! Overlays are collected rather than drawn in place: a floating panel belongs | |
| 9 | +//! over the whole screen, so it is painted after everything else at the size it | |
| 10 | +//! asked for, in the middle. | |
| 11 | + | |
| 12 | +use crate::layout::{self, wrap, Align}; | |
| 13 | +use crate::screen::{attr, Color, Rect, Screen, Style}; | |
| 14 | +use crate::tree::{Props, Tag, Tree}; | |
| 15 | + | |
| 16 | +const SPINNER: [char; 8] = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧']; | |
| 17 | + | |
| 18 | +/// What one frame of painting learned about the tree, for the input half to | |
| 19 | +/// use on the next key or click. | |
| 20 | +#[derive(Clone, Debug, Default)] | |
| 21 | +pub struct Painted { | |
| 22 | + /// Focusable nodes in paint order — the order Tab walks. | |
| 23 | + pub ring: Vec<u32>, | |
| 24 | + /// Where each of them ended up. | |
| 25 | + pub hits: Vec<(u32, Rect)>, | |
| 26 | + /// How far each scroll node's viewport actually was, after clamping to the | |
| 27 | + /// content it had. Written back so a caller cannot scroll past the end. | |
| 28 | + pub scrolled: Vec<(u32, u16)>, | |
| 29 | + /// Where the cursor should sit — the focused entry's caret, if any. | |
| 30 | + pub cursor: Option<(u16, u16)>, | |
| 31 | +} | |
| 32 | + | |
| 33 | +struct Painter<'a> { | |
| 34 | + tree: &'a Tree, | |
| 35 | + screen: &'a mut Screen, | |
| 36 | + focus: u32, | |
| 37 | + /// Where the caret sits in the focused entry's text, in characters. | |
| 38 | + caret: usize, | |
| 39 | + tick: u64, | |
| 40 | + out: Painted, | |
| 41 | + overlays: Vec<u32>, | |
| 42 | +} | |
| 43 | + | |
| 44 | +/// Paint the whole tree. `focus` is the node the ring is currently on and | |
| 45 | +/// `tick` advances the spinners. | |
| 46 | +pub fn frame(tree: &Tree, screen: &mut Screen, focus: u32, caret: usize, tick: u64) -> Painted { | |
| 47 | + screen.clear(); | |
| 48 | + let mut painter = Painter { | |
| 49 | + tree, | |
| 50 | + screen, | |
| 51 | + focus, | |
| 52 | + caret, | |
| 53 | + tick, | |
| 54 | + out: Painted::default(), | |
| 55 | + overlays: Vec::new(), | |
| 56 | + }; | |
| 57 | + let area = painter.screen.rect(); | |
| 58 | + painter.node(tree.root(), area, Style::default(), true); | |
| 59 | + | |
| 60 | + // Overlays float above the rest, so they are painted after it — and a | |
| 61 | + // click landing on one must beat a click on whatever it covers, which is | |
| 62 | + // what putting their hit rects first does. | |
| 63 | + let overlays = std::mem::take(&mut painter.overlays); | |
| 64 | + let below = std::mem::take(&mut painter.out.hits); | |
| 65 | + for id in overlays { | |
| 66 | + painter.overlay(id, area); | |
| 67 | + } | |
| 68 | + painter.out.hits.extend(below); | |
| 69 | + painter.out | |
| 70 | +} | |
| 71 | + | |
| 72 | +impl Painter<'_> { | |
| 73 | + fn style_for(&self, props: &Props, inherited: Style, enabled: bool) -> Style { | |
| 74 | + let mut style = inherited; | |
| 75 | + if let Some(fg) = Color::parse(props.str("color")) { | |
| 76 | + style.fg = fg; | |
| 77 | + } | |
| 78 | + if let Some(bg) = Color::parse(props.str("bg")) { | |
| 79 | + style.bg = bg; | |
| 80 | + } | |
| 81 | + for (key, bit) in [ | |
| 82 | + ("bold", attr::BOLD), | |
| 83 | + ("dim", attr::DIM), | |
| 84 | + ("underline", attr::UNDERLINE), | |
| 85 | + ("reverse", attr::REVERSE), | |
| 86 | + ("blink", attr::BLINK), | |
| 87 | + ("italic", attr::ITALIC), | |
| 88 | + ] { | |
| 89 | + if props.bool(key, false) { | |
| 90 | + style.attrs |= bit; | |
| 91 | + } | |
| 92 | + } | |
| 93 | + if !enabled { | |
| 94 | + // `:sensitive false` dims the widget *and its whole subtree*, which | |
| 95 | + // is what it means in every other glimmer backend. | |
| 96 | + style.attrs |= attr::DIM; | |
| 97 | + } | |
| 98 | + style | |
| 99 | + } | |
| 100 | + | |
| 101 | + fn node(&mut self, id: u32, area: Rect, inherited: Style, enabled: bool) { | |
| 102 | + if area.is_empty() || !self.tree.exists(id) { | |
| 103 | + return; | |
| 104 | + } | |
| 105 | + let tag = self.tree.tag(id); | |
| 106 | + let props = self.tree.props(id); | |
| 107 | + let enabled = enabled && props.bool("sensitive", true); | |
| 108 | + let style = self.style_for(&props, inherited, enabled); | |
| 109 | + if props.has("bg") { | |
| 110 | + self.screen.fill(area, style); | |
| 111 | + } | |
| 112 | + if enabled && tag.focusable() { | |
| 113 | + self.out.ring.push(id); | |
| 114 | + self.out.hits.push((id, area)); | |
| 115 | + } | |
| 116 | + | |
| 117 | + let pad = layout::inset(&tag, &props); | |
| 118 | + let inner = area.shrink(pad); | |
| 119 | + match tag { | |
| 120 | + Tag::Overlay => self.overlays.push(id), | |
| 121 | + Tag::Frame => { | |
| 122 | + self.border(area, props.label(), style); | |
| 123 | + self.children(id, inner, style, enabled); | |
| 124 | + } | |
| 125 | + Tag::Scroll => self.scroll(id, inner, style, enabled), | |
| 126 | + Tag::Box | Tag::Window | Tag::Unknown(_) => self.children(id, inner, style, enabled), | |
| 127 | + Tag::Label => self.wrapped(inner, props.label(), style), | |
| 128 | + Tag::Title => self.wrapped(inner, props.label(), style.with(attr::BOLD)), | |
| 129 | + Tag::DimLabel => self.wrapped(inner, props.label(), style.with(attr::DIM)), | |
| 130 | + Tag::Button => self.button(id, inner, &props, style), | |
| 131 | + Tag::CheckButton => self.check(id, inner, &props, style), | |
| 132 | + Tag::Entry => self.entry(id, inner, &props, style), | |
| 133 | + Tag::Separator => self.separator(inner, style), | |
| 134 | + Tag::Progress => self.progress(inner, &props, style), | |
| 135 | + Tag::Spinner => { | |
| 136 | + let ch = SPINNER[(self.tick as usize) % SPINNER.len()]; | |
| 137 | + self.screen.set(inner.x, inner.y, ch, style); | |
| 138 | + } | |
| 139 | + Tag::Listbox => self.listbox(id, inner, &props, style, enabled), | |
| 140 | + // A spacer is the absence of anything; the clear at the top of the | |
| 141 | + // frame has already drawn it. | |
| 142 | + Tag::Spacer => {} | |
| 143 | + } | |
| 144 | + } | |
| 145 | + | |
| 146 | + fn children(&mut self, id: u32, area: Rect, style: Style, enabled: bool) { | |
| 147 | + if area.is_empty() { | |
| 148 | + return; | |
| 149 | + } | |
| 150 | + let rects = layout::children_rects(self.tree, id, area); | |
| 151 | + for (child, rect) in self.tree.children(id).into_iter().zip(rects) { | |
| 152 | + // Clip to the parent: a child asking for more rows than are left | |
| 153 | + // paints what fits rather than over its neighbours. | |
| 154 | + let bottom = area.y.saturating_add(area.h); | |
| 155 | + let right = area.x.saturating_add(area.w); | |
| 156 | + if rect.y >= bottom || rect.x >= right { | |
| 157 | + continue; | |
| 158 | + } | |
| 159 | + let clipped = Rect::new( | |
| 160 | + rect.x, | |
| 161 | + rect.y, | |
| 162 | + rect.w.min(right - rect.x), | |
| 163 | + rect.h.min(bottom - rect.y), | |
| 164 | + ); | |
| 165 | + self.node(child, clipped, style, enabled); | |
| 166 | + } | |
| 167 | + } | |
| 168 | + | |
| 169 | + fn wrapped(&mut self, area: Rect, text: &str, style: Style) { | |
| 170 | + for (i, line) in wrap(text, area.w).into_iter().enumerate() { | |
| 171 | + if i as u16 >= area.h { | |
| 172 | + break; | |
| 173 | + } | |
| 174 | + self.screen | |
| 175 | + .text(area.x, area.y + i as u16, area.w, &line, style); | |
| 176 | + } | |
| 177 | + } | |
| 178 | + | |
| 179 | + fn focused(&self, id: u32) -> bool { | |
| 180 | + self.focus == id | |
| 181 | + } | |
| 182 | + | |
| 183 | + fn button(&mut self, id: u32, area: Rect, props: &Props, style: Style) { | |
| 184 | + let mut style = match props.str("kind") { | |
| 185 | + "primary" => style.with(attr::BOLD), | |
| 186 | + "destructive" => style.fg(Color::parse("red").unwrap_or_default()), | |
| 187 | + _ => style, | |
| 188 | + }; | |
| 189 | + if self.focused(id) { | |
| 190 | + style = style.with(attr::REVERSE); | |
| 191 | + } | |
| 192 | + let label = format!("[ {} ]", props.label()); | |
| 193 | + self.screen.text(area.x, area.y, area.w, &label, style); | |
| 194 | + } | |
| 195 | + | |
| 196 | + fn check(&mut self, id: u32, area: Rect, props: &Props, style: Style) { | |
| 197 | + let style = if self.focused(id) { | |
| 198 | + style.with(attr::REVERSE) | |
| 199 | + } else { | |
| 200 | + style | |
| 201 | + }; | |
| 202 | + let mark = if props.bool("active", false) { | |
| 203 | + 'x' | |
| 204 | + } else { | |
| 205 | + ' ' | |
| 206 | + }; | |
| 207 | + let label = format!("[{mark}] {}", props.label()); | |
| 208 | + self.screen.text(area.x, area.y, area.w, &label, style); | |
| 209 | + } | |
| 210 | + | |
| 211 | + fn entry(&mut self, id: u32, area: Rect, props: &Props, style: Style) { | |
| 212 | + let focused = self.focused(id); | |
| 213 | + let text = props.str("text"); | |
| 214 | + let showing_placeholder = text.is_empty(); | |
| 215 | + let shown = layout::entry_text(props); | |
| 216 | + let mut style = style.with(attr::UNDERLINE); | |
| 217 | + if showing_placeholder { | |
| 218 | + style = style.with(attr::DIM); | |
| 219 | + } | |
| 220 | + if focused { | |
| 221 | + style = style.with(attr::REVERSE); | |
| 222 | + } | |
| 223 | + // The field is its whole rect, not just the text in it: a reader needs | |
| 224 | + // to see where it can type before it has typed anything. | |
| 225 | + self.screen.fill(area, style); | |
| 226 | + let rows = area.h.max(1); | |
| 227 | + let lines = if props.cells("rows", 1) > 1 { | |
| 228 | + wrap(&shown, area.w) | |
| 229 | + } else { | |
| 230 | + vec![shown.chars().collect::<String>()] | |
| 231 | + }; | |
| 232 | + let caret = self.caret.min(text.chars().count()); | |
| 233 | + // A line longer than the field scrolls sideways to keep the caret in | |
| 234 | + // view — the end of it is where someone is usually typing, but not | |
| 235 | + // always, so it follows the caret rather than the end. | |
| 236 | + for (i, line) in lines.iter().take(rows as usize).enumerate() { | |
| 237 | + let len = line.chars().count(); | |
| 238 | + let last = i + 1 == lines.len().min(rows as usize); | |
| 239 | + let window = area.w.saturating_sub(1).max(1) as usize; | |
| 240 | + let from = if last && !showing_placeholder { | |
| 241 | + caret.saturating_sub(window) | |
| 242 | + } else { | |
| 243 | + len.saturating_sub(window) | |
| 244 | + }; | |
| 245 | + let visible: String = line.chars().skip(from).collect(); | |
| 246 | + self.screen | |
| 247 | + .text(area.x, area.y + i as u16, area.w, &visible, style); | |
| 248 | + if focused && last { | |
| 249 | + let col = if showing_placeholder { | |
| 250 | + 0 | |
| 251 | + } else { | |
| 252 | + caret | |
| 253 | + .saturating_sub(from) | |
| 254 | + .min(area.w.saturating_sub(1) as usize) | |
| 255 | + }; | |
| 256 | + self.out.cursor = Some((area.x.saturating_add(col as u16), area.y + i as u16)); | |
| 257 | + } | |
| 258 | + } | |
| 259 | + } | |
| 260 | + | |
| 261 | + fn separator(&mut self, area: Rect, style: Style) { | |
| 262 | + for x in area.x..area.x.saturating_add(area.w) { | |
| 263 | + self.screen.set(x, area.y, '─', style); | |
| 264 | + } | |
| 265 | + } | |
| 266 | + | |
| 267 | + fn progress(&mut self, area: Rect, props: &Props, style: Style) { | |
| 268 | + let value = props.num("value", 0.0).clamp(0.0, 1.0); | |
| 269 | + let filled = (value * area.w as f64).round() as u16; | |
| 270 | + for x in 0..area.w { | |
| 271 | + let ch = if x < filled { '█' } else { '░' }; | |
| 272 | + self.screen.set(area.x + x, area.y, ch, style); | |
| 273 | + } | |
| 274 | + let label = props.label(); | |
| 275 | + if !label.is_empty() { | |
| 276 | + let at = area.x + (area.w.saturating_sub(label.chars().count() as u16)) / 2; | |
| 277 | + self.screen.text(at, area.y, area.w, label, style); | |
| 278 | + } | |
| 279 | + } | |
| 280 | + | |
| 281 | + fn listbox(&mut self, id: u32, area: Rect, props: &Props, style: Style, enabled: bool) { | |
| 282 | + let items = self.tree.children(id); | |
| 283 | + // No `:selected` at all means the cursor is on the first row: a list | |
| 284 | + // with no cursor cannot be moved with the arrows, and a caller that | |
| 285 | + // wants none says so with -1. | |
| 286 | + let selected = props.num("selected", 0.0); | |
| 287 | + let selected = if selected < 0.0 { | |
| 288 | + None | |
| 289 | + } else { | |
| 290 | + Some(selected as usize) | |
| 291 | + }; | |
| 292 | + // Keep the cursor on screen: scroll only as far as it takes. | |
| 293 | + let rows = area.h as usize; | |
| 294 | + let first = match selected { | |
| 295 | + Some(sel) if rows > 0 && sel >= rows => sel + 1 - rows, | |
| 296 | + _ => 0, | |
| 297 | + }; | |
| 298 | + for (row, item) in items.iter().skip(first).take(rows).enumerate() { | |
| 299 | + let y = area.y + row as u16; | |
| 300 | + let chosen = selected == Some(first + row); | |
| 301 | + let mut row_style = style; | |
| 302 | + if chosen { | |
| 303 | + row_style = row_style.with(if self.focused(id) { | |
| 304 | + attr::REVERSE | |
| 305 | + } else { | |
| 306 | + attr::BOLD | |
| 307 | + }); | |
| 308 | + self.screen.fill(Rect::new(area.x, y, area.w, 1), row_style); | |
| 309 | + } | |
| 310 | + let marker = if chosen { "› " } else { " " }; | |
| 311 | + self.screen.text(area.x, y, area.w, marker, row_style); | |
| 312 | + let cell = Rect::new(area.x + 2, y, area.w.saturating_sub(2), 1); | |
| 313 | + self.node(*item, cell, row_style, enabled); | |
| 314 | + } | |
| 315 | + } | |
| 316 | + | |
| 317 | + fn scroll(&mut self, id: u32, area: Rect, style: Style, enabled: bool) { | |
| 318 | + let props = self.tree.props(id); | |
| 319 | + // The content is painted at its full height into a screen of its own, | |
| 320 | + // then the visible window of it is copied across. Doing it this way | |
| 321 | + // means every widget inside a scroll paints exactly as it would | |
| 322 | + // outside one — nothing has to know it is being clipped. | |
| 323 | + let content_h = self | |
| 324 | + .tree | |
| 325 | + .children(id) | |
| 326 | + .iter() | |
| 327 | + .map(|c| layout::height_for_width(self.tree, *c, area.w)) | |
| 328 | + .sum::<u16>() | |
| 329 | + .max(1); | |
| 330 | + let max_offset = content_h.saturating_sub(area.h); | |
| 331 | + let offset = props.cells("offset", 0).min(max_offset); | |
| 332 | + self.out.scrolled.push((id, offset)); | |
| 333 | + | |
| 334 | + let mut buffer = Screen::new(area.w, content_h); | |
| 335 | + let mut inner = Painter { | |
| 336 | + tree: self.tree, | |
| 337 | + screen: &mut buffer, | |
| 338 | + focus: self.focus, | |
| 339 | + caret: self.caret, | |
| 340 | + tick: self.tick, | |
| 341 | + out: Painted::default(), | |
| 342 | + overlays: Vec::new(), | |
| 343 | + }; | |
| 344 | + let full = Rect::new(0, 0, area.w, content_h); | |
| 345 | + inner.children(id, full, style, enabled); | |
| 346 | + let learned = inner.out; | |
| 347 | + | |
| 348 | + for y in 0..area.h { | |
| 349 | + for x in 0..area.w { | |
| 350 | + if let Some(cell) = buffer.cell(x, y + offset) { | |
| 351 | + self.screen.set(area.x + x, area.y + y, cell.ch, cell.style); | |
| 352 | + } | |
| 353 | + } | |
| 354 | + } | |
| 355 | + // Widgets inside keep their place in the focus ring; their rects move | |
| 356 | + // by the viewport, and the ones scrolled out of sight take no clicks. | |
| 357 | + self.out.ring.extend(learned.ring); | |
| 358 | + for (node, rect) in learned.hits { | |
| 359 | + if rect.y >= offset && rect.y < offset.saturating_add(area.h) { | |
| 360 | + self.out.hits.push(( | |
| 361 | + node, | |
| 362 | + Rect::new( | |
| 363 | + area.x + rect.x, | |
| 364 | + area.y + rect.y - offset, | |
| 365 | + rect.w, | |
| 366 | + rect.h.min(area.h), | |
| 367 | + ), | |
| 368 | + )); | |
| 369 | + } | |
| 370 | + } | |
| 371 | + self.out.scrolled.extend(learned.scrolled); | |
| 372 | + if let Some((cx, cy)) = learned.cursor { | |
| 373 | + if cy >= offset && cy < offset.saturating_add(area.h) { | |
| 374 | + self.out.cursor = Some((area.x + cx, area.y + cy - offset)); | |
| 375 | + } | |
| 376 | + } | |
| 377 | + } | |
| 378 | + | |
| 379 | + fn overlay(&mut self, id: u32, screen: Rect) { | |
| 380 | + let props = self.tree.props(id); | |
| 381 | + let w = layout::width(self.tree, id, false).min(screen.w); | |
| 382 | + let h = layout::height_for_width(self.tree, id, w).min(screen.h); | |
| 383 | + let (x, y) = ( | |
| 384 | + screen.x + Align::Center.offset_pub(w, screen.w), | |
| 385 | + screen.y + Align::Center.offset_pub(h, screen.h), | |
| 386 | + ); | |
| 387 | + let area = Rect::new(x, y, w, h); | |
| 388 | + let style = self.style_for(&props, Style::default(), true); | |
| 389 | + // Blank what is under it: a floating panel that shows the screen | |
| 390 | + // through its gaps is unreadable. | |
| 391 | + for row in area.y..area.y + area.h { | |
| 392 | + for col in area.x..area.x + area.w { | |
| 393 | + self.screen.set(col, row, ' ', style); | |
| 394 | + } | |
| 395 | + } | |
| 396 | + self.border(area, props.label(), style); | |
| 397 | + let pad = layout::inset(&Tag::Overlay, &props); | |
| 398 | + self.children(id, area.shrink(pad), style, true); | |
| 399 | + } | |
| 400 | + | |
| 401 | + /// A single-line box, with `label` set into the top edge when there is one. | |
| 402 | + fn border(&mut self, area: Rect, label: &str, style: Style) { | |
| 403 | + if area.w < 2 || area.h < 2 { | |
| 404 | + return; | |
| 405 | + } | |
| 406 | + let (x1, y1) = (area.x + area.w - 1, area.y + area.h - 1); | |
| 407 | + for x in area.x..=x1 { | |
| 408 | + self.screen.set(x, area.y, '─', style); | |
| 409 | + self.screen.set(x, y1, '─', style); | |
| 410 | + } | |
| 411 | + for y in area.y..=y1 { | |
| 412 | + self.screen.set(area.x, y, '│', style); | |
| 413 | + self.screen.set(x1, y, '│', style); | |
| 414 | + } | |
| 415 | + self.screen.set(area.x, area.y, '┌', style); | |
| 416 | + self.screen.set(x1, area.y, '┐', style); | |
| 417 | + self.screen.set(area.x, y1, '└', style); | |
| 418 | + self.screen.set(x1, y1, '┘', style); | |
| 419 | + if !label.is_empty() && area.w > 4 { | |
| 420 | + let text = format!(" {label} "); | |
| 421 | + self.screen.text( | |
| 422 | + area.x + 1, | |
| 423 | + area.y, | |
| 424 | + area.w - 2, | |
| 425 | + &text, | |
| 426 | + style.with(attr::BOLD), | |
| 427 | + ); | |
| 428 | + } | |
| 429 | + } | |
| 430 | +} | |
| 431 | + | |
| 432 | +impl Align { | |
| 433 | + /// [`Align::offset`] is private to the layout module; overlays are the one | |
| 434 | + /// caller outside it that centres something by hand. | |
| 435 | + fn offset_pub(self, size: u16, avail: u16) -> u16 { | |
| 436 | + layout::place(self, size, avail).0 | |
| 437 | + } | |
| 438 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,438 @@ | |||
| 1 | +//! Drawing the tree into a grid of cells. | ||
| 2 | +//! | ||
| 3 | +//! One pass, top to bottom: each node is handed a rect by [`crate::layout`] and | ||
| 4 | +//! paints itself into it. Two things fall out of the walk and are kept — | ||
| 5 | +//! the focus ring, in the order the widgets were painted, and every focusable | ||
| 6 | +//! widget's rect, so a mouse click can be turned back into a node. | ||
| 7 | +//! | ||
| 8 | +//! Overlays are collected rather than drawn in place: a floating panel belongs | ||
| 9 | +//! over the whole screen, so it is painted after everything else at the size it | ||
| 10 | +//! asked for, in the middle. | ||
| 11 | + | ||
| 12 | +use crate::layout::{self, wrap, Align}; | ||
| 13 | +use crate::screen::{attr, Color, Rect, Screen, Style}; | ||
| 14 | +use crate::tree::{Props, Tag, Tree}; | ||
| 15 | + | ||
| 16 | +const SPINNER: [char; 8] = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧']; | ||
| 17 | + | ||
| 18 | +/// What one frame of painting learned about the tree, for the input half to | ||
| 19 | +/// use on the next key or click. | ||
| 20 | +#[derive(Clone, Debug, Default)] | ||
| 21 | +pub struct Painted { | ||
| 22 | + /// Focusable nodes in paint order — the order Tab walks. | ||
| 23 | + pub ring: Vec<u32>, | ||
| 24 | + /// Where each of them ended up. | ||
| 25 | + pub hits: Vec<(u32, Rect)>, | ||
| 26 | + /// How far each scroll node's viewport actually was, after clamping to the | ||
| 27 | + /// content it had. Written back so a caller cannot scroll past the end. | ||
| 28 | + pub scrolled: Vec<(u32, u16)>, | ||
| 29 | + /// Where the cursor should sit — the focused entry's caret, if any. | ||
| 30 | + pub cursor: Option<(u16, u16)>, | ||
| 31 | +} | ||
| 32 | + | ||
| 33 | +struct Painter<'a> { | ||
| 34 | + tree: &'a Tree, | ||
| 35 | + screen: &'a mut Screen, | ||
| 36 | + focus: u32, | ||
| 37 | + /// Where the caret sits in the focused entry's text, in characters. | ||
| 38 | + caret: usize, | ||
| 39 | + tick: u64, | ||
| 40 | + out: Painted, | ||
| 41 | + overlays: Vec<u32>, | ||
| 42 | +} | ||
| 43 | + | ||
| 44 | +/// Paint the whole tree. `focus` is the node the ring is currently on and | ||
| 45 | +/// `tick` advances the spinners. | ||
| 46 | +pub fn frame(tree: &Tree, screen: &mut Screen, focus: u32, caret: usize, tick: u64) -> Painted { | ||
| 47 | + screen.clear(); | ||
| 48 | + let mut painter = Painter { | ||
| 49 | + tree, | ||
| 50 | + screen, | ||
| 51 | + focus, | ||
| 52 | + caret, | ||
| 53 | + tick, | ||
| 54 | + out: Painted::default(), | ||
| 55 | + overlays: Vec::new(), | ||
| 56 | + }; | ||
| 57 | + let area = painter.screen.rect(); | ||
| 58 | + painter.node(tree.root(), area, Style::default(), true); | ||
| 59 | + | ||
| 60 | + // Overlays float above the rest, so they are painted after it — and a | ||
| 61 | + // click landing on one must beat a click on whatever it covers, which is | ||
| 62 | + // what putting their hit rects first does. | ||
| 63 | + let overlays = std::mem::take(&mut painter.overlays); | ||
| 64 | + let below = std::mem::take(&mut painter.out.hits); | ||
| 65 | + for id in overlays { | ||
| 66 | + painter.overlay(id, area); | ||
| 67 | + } | ||
| 68 | + painter.out.hits.extend(below); | ||
| 69 | + painter.out | ||
| 70 | +} | ||
| 71 | + | ||
| 72 | +impl Painter<'_> { | ||
| 73 | + fn style_for(&self, props: &Props, inherited: Style, enabled: bool) -> Style { | ||
| 74 | + let mut style = inherited; | ||
| 75 | + if let Some(fg) = Color::parse(props.str("color")) { | ||
| 76 | + style.fg = fg; | ||
| 77 | + } | ||
| 78 | + if let Some(bg) = Color::parse(props.str("bg")) { | ||
| 79 | + style.bg = bg; | ||
| 80 | + } | ||
| 81 | + for (key, bit) in [ | ||
| 82 | + ("bold", attr::BOLD), | ||
| 83 | + ("dim", attr::DIM), | ||
| 84 | + ("underline", attr::UNDERLINE), | ||
| 85 | + ("reverse", attr::REVERSE), | ||
| 86 | + ("blink", attr::BLINK), | ||
| 87 | + ("italic", attr::ITALIC), | ||
| 88 | + ] { | ||
| 89 | + if props.bool(key, false) { | ||
| 90 | + style.attrs |= bit; | ||
| 91 | + } | ||
| 92 | + } | ||
| 93 | + if !enabled { | ||
| 94 | + // `:sensitive false` dims the widget *and its whole subtree*, which | ||
| 95 | + // is what it means in every other glimmer backend. | ||
| 96 | + style.attrs |= attr::DIM; | ||
| 97 | + } | ||
| 98 | + style | ||
| 99 | + } | ||
| 100 | + | ||
| 101 | + fn node(&mut self, id: u32, area: Rect, inherited: Style, enabled: bool) { | ||
| 102 | + if area.is_empty() || !self.tree.exists(id) { | ||
| 103 | + return; | ||
| 104 | + } | ||
| 105 | + let tag = self.tree.tag(id); | ||
| 106 | + let props = self.tree.props(id); | ||
| 107 | + let enabled = enabled && props.bool("sensitive", true); | ||
| 108 | + let style = self.style_for(&props, inherited, enabled); | ||
| 109 | + if props.has("bg") { | ||
| 110 | + self.screen.fill(area, style); | ||
| 111 | + } | ||
| 112 | + if enabled && tag.focusable() { | ||
| 113 | + self.out.ring.push(id); | ||
| 114 | + self.out.hits.push((id, area)); | ||
| 115 | + } | ||
| 116 | + | ||
| 117 | + let pad = layout::inset(&tag, &props); | ||
| 118 | + let inner = area.shrink(pad); | ||
| 119 | + match tag { | ||
| 120 | + Tag::Overlay => self.overlays.push(id), | ||
| 121 | + Tag::Frame => { | ||
| 122 | + self.border(area, props.label(), style); | ||
| 123 | + self.children(id, inner, style, enabled); | ||
| 124 | + } | ||
| 125 | + Tag::Scroll => self.scroll(id, inner, style, enabled), | ||
| 126 | + Tag::Box | Tag::Window | Tag::Unknown(_) => self.children(id, inner, style, enabled), | ||
| 127 | + Tag::Label => self.wrapped(inner, props.label(), style), | ||
| 128 | + Tag::Title => self.wrapped(inner, props.label(), style.with(attr::BOLD)), | ||
| 129 | + Tag::DimLabel => self.wrapped(inner, props.label(), style.with(attr::DIM)), | ||
| 130 | + Tag::Button => self.button(id, inner, &props, style), | ||
| 131 | + Tag::CheckButton => self.check(id, inner, &props, style), | ||
| 132 | + Tag::Entry => self.entry(id, inner, &props, style), | ||
| 133 | + Tag::Separator => self.separator(inner, style), | ||
| 134 | + Tag::Progress => self.progress(inner, &props, style), | ||
| 135 | + Tag::Spinner => { | ||
| 136 | + let ch = SPINNER[(self.tick as usize) % SPINNER.len()]; | ||
| 137 | + self.screen.set(inner.x, inner.y, ch, style); | ||
| 138 | + } | ||
| 139 | + Tag::Listbox => self.listbox(id, inner, &props, style, enabled), | ||
| 140 | + // A spacer is the absence of anything; the clear at the top of the | ||
| 141 | + // frame has already drawn it. | ||
| 142 | + Tag::Spacer => {} | ||
| 143 | + } | ||
| 144 | + } | ||
| 145 | + | ||
| 146 | + fn children(&mut self, id: u32, area: Rect, style: Style, enabled: bool) { | ||
| 147 | + if area.is_empty() { | ||
| 148 | + return; | ||
| 149 | + } | ||
| 150 | + let rects = layout::children_rects(self.tree, id, area); | ||
| 151 | + for (child, rect) in self.tree.children(id).into_iter().zip(rects) { | ||
| 152 | + // Clip to the parent: a child asking for more rows than are left | ||
| 153 | + // paints what fits rather than over its neighbours. | ||
| 154 | + let bottom = area.y.saturating_add(area.h); | ||
| 155 | + let right = area.x.saturating_add(area.w); | ||
| 156 | + if rect.y >= bottom || rect.x >= right { | ||
| 157 | + continue; | ||
| 158 | + } | ||
| 159 | + let clipped = Rect::new( | ||
| 160 | + rect.x, | ||
| 161 | + rect.y, | ||
| 162 | + rect.w.min(right - rect.x), | ||
| 163 | + rect.h.min(bottom - rect.y), | ||
| 164 | + ); | ||
| 165 | + self.node(child, clipped, style, enabled); | ||
| 166 | + } | ||
| 167 | + } | ||
| 168 | + | ||
| 169 | + fn wrapped(&mut self, area: Rect, text: &str, style: Style) { | ||
| 170 | + for (i, line) in wrap(text, area.w).into_iter().enumerate() { | ||
| 171 | + if i as u16 >= area.h { | ||
| 172 | + break; | ||
| 173 | + } | ||
| 174 | + self.screen | ||
| 175 | + .text(area.x, area.y + i as u16, area.w, &line, style); | ||
| 176 | + } | ||
| 177 | + } | ||
| 178 | + | ||
| 179 | + fn focused(&self, id: u32) -> bool { | ||
| 180 | + self.focus == id | ||
| 181 | + } | ||
| 182 | + | ||
| 183 | + fn button(&mut self, id: u32, area: Rect, props: &Props, style: Style) { | ||
| 184 | + let mut style = match props.str("kind") { | ||
| 185 | + "primary" => style.with(attr::BOLD), | ||
| 186 | + "destructive" => style.fg(Color::parse("red").unwrap_or_default()), | ||
| 187 | + _ => style, | ||
| 188 | + }; | ||
| 189 | + if self.focused(id) { | ||
| 190 | + style = style.with(attr::REVERSE); | ||
| 191 | + } | ||
| 192 | + let label = format!("[ {} ]", props.label()); | ||
| 193 | + self.screen.text(area.x, area.y, area.w, &label, style); | ||
| 194 | + } | ||
| 195 | + | ||
| 196 | + fn check(&mut self, id: u32, area: Rect, props: &Props, style: Style) { | ||
| 197 | + let style = if self.focused(id) { | ||
| 198 | + style.with(attr::REVERSE) | ||
| 199 | + } else { | ||
| 200 | + style | ||
| 201 | + }; | ||
| 202 | + let mark = if props.bool("active", false) { | ||
| 203 | + 'x' | ||
| 204 | + } else { | ||
| 205 | + ' ' | ||
| 206 | + }; | ||
| 207 | + let label = format!("[{mark}] {}", props.label()); | ||
| 208 | + self.screen.text(area.x, area.y, area.w, &label, style); | ||
| 209 | + } | ||
| 210 | + | ||
| 211 | + fn entry(&mut self, id: u32, area: Rect, props: &Props, style: Style) { | ||
| 212 | + let focused = self.focused(id); | ||
| 213 | + let text = props.str("text"); | ||
| 214 | + let showing_placeholder = text.is_empty(); | ||
| 215 | + let shown = layout::entry_text(props); | ||
| 216 | + let mut style = style.with(attr::UNDERLINE); | ||
| 217 | + if showing_placeholder { | ||
| 218 | + style = style.with(attr::DIM); | ||
| 219 | + } | ||
| 220 | + if focused { | ||
| 221 | + style = style.with(attr::REVERSE); | ||
| 222 | + } | ||
| 223 | + // The field is its whole rect, not just the text in it: a reader needs | ||
| 224 | + // to see where it can type before it has typed anything. | ||
| 225 | + self.screen.fill(area, style); | ||
| 226 | + let rows = area.h.max(1); | ||
| 227 | + let lines = if props.cells("rows", 1) > 1 { | ||
| 228 | + wrap(&shown, area.w) | ||
| 229 | + } else { | ||
| 230 | + vec![shown.chars().collect::<String>()] | ||
| 231 | + }; | ||
| 232 | + let caret = self.caret.min(text.chars().count()); | ||
| 233 | + // A line longer than the field scrolls sideways to keep the caret in | ||
| 234 | + // view — the end of it is where someone is usually typing, but not | ||
| 235 | + // always, so it follows the caret rather than the end. | ||
| 236 | + for (i, line) in lines.iter().take(rows as usize).enumerate() { | ||
| 237 | + let len = line.chars().count(); | ||
| 238 | + let last = i + 1 == lines.len().min(rows as usize); | ||
| 239 | + let window = area.w.saturating_sub(1).max(1) as usize; | ||
| 240 | + let from = if last && !showing_placeholder { | ||
| 241 | + caret.saturating_sub(window) | ||
| 242 | + } else { | ||
| 243 | + len.saturating_sub(window) | ||
| 244 | + }; | ||
| 245 | + let visible: String = line.chars().skip(from).collect(); | ||
| 246 | + self.screen | ||
| 247 | + .text(area.x, area.y + i as u16, area.w, &visible, style); | ||
| 248 | + if focused && last { | ||
| 249 | + let col = if showing_placeholder { | ||
| 250 | + 0 | ||
| 251 | + } else { | ||
| 252 | + caret | ||
| 253 | + .saturating_sub(from) | ||
| 254 | + .min(area.w.saturating_sub(1) as usize) | ||
| 255 | + }; | ||
| 256 | + self.out.cursor = Some((area.x.saturating_add(col as u16), area.y + i as u16)); | ||
| 257 | + } | ||
| 258 | + } | ||
| 259 | + } | ||
| 260 | + | ||
| 261 | + fn separator(&mut self, area: Rect, style: Style) { | ||
| 262 | + for x in area.x..area.x.saturating_add(area.w) { | ||
| 263 | + self.screen.set(x, area.y, '─', style); | ||
| 264 | + } | ||
| 265 | + } | ||
| 266 | + | ||
| 267 | + fn progress(&mut self, area: Rect, props: &Props, style: Style) { | ||
| 268 | + let value = props.num("value", 0.0).clamp(0.0, 1.0); | ||
| 269 | + let filled = (value * area.w as f64).round() as u16; | ||
| 270 | + for x in 0..area.w { | ||
| 271 | + let ch = if x < filled { '█' } else { '░' }; | ||
| 272 | + self.screen.set(area.x + x, area.y, ch, style); | ||
| 273 | + } | ||
| 274 | + let label = props.label(); | ||
| 275 | + if !label.is_empty() { | ||
| 276 | + let at = area.x + (area.w.saturating_sub(label.chars().count() as u16)) / 2; | ||
| 277 | + self.screen.text(at, area.y, area.w, label, style); | ||
| 278 | + } | ||
| 279 | + } | ||
| 280 | + | ||
| 281 | + fn listbox(&mut self, id: u32, area: Rect, props: &Props, style: Style, enabled: bool) { | ||
| 282 | + let items = self.tree.children(id); | ||
| 283 | + // No `:selected` at all means the cursor is on the first row: a list | ||
| 284 | + // with no cursor cannot be moved with the arrows, and a caller that | ||
| 285 | + // wants none says so with -1. | ||
| 286 | + let selected = props.num("selected", 0.0); | ||
| 287 | + let selected = if selected < 0.0 { | ||
| 288 | + None | ||
| 289 | + } else { | ||
| 290 | + Some(selected as usize) | ||
| 291 | + }; | ||
| 292 | + // Keep the cursor on screen: scroll only as far as it takes. | ||
| 293 | + let rows = area.h as usize; | ||
| 294 | + let first = match selected { | ||
| 295 | + Some(sel) if rows > 0 && sel >= rows => sel + 1 - rows, | ||
| 296 | + _ => 0, | ||
| 297 | + }; | ||
| 298 | + for (row, item) in items.iter().skip(first).take(rows).enumerate() { | ||
| 299 | + let y = area.y + row as u16; | ||
| 300 | + let chosen = selected == Some(first + row); | ||
| 301 | + let mut row_style = style; | ||
| 302 | + if chosen { | ||
| 303 | + row_style = row_style.with(if self.focused(id) { | ||
| 304 | + attr::REVERSE | ||
| 305 | + } else { | ||
| 306 | + attr::BOLD | ||
| 307 | + }); | ||
| 308 | + self.screen.fill(Rect::new(area.x, y, area.w, 1), row_style); | ||
| 309 | + } | ||
| 310 | + let marker = if chosen { "› " } else { " " }; | ||
| 311 | + self.screen.text(area.x, y, area.w, marker, row_style); | ||
| 312 | + let cell = Rect::new(area.x + 2, y, area.w.saturating_sub(2), 1); | ||
| 313 | + self.node(*item, cell, row_style, enabled); | ||
| 314 | + } | ||
| 315 | + } | ||
| 316 | + | ||
| 317 | + fn scroll(&mut self, id: u32, area: Rect, style: Style, enabled: bool) { | ||
| 318 | + let props = self.tree.props(id); | ||
| 319 | + // The content is painted at its full height into a screen of its own, | ||
| 320 | + // then the visible window of it is copied across. Doing it this way | ||
| 321 | + // means every widget inside a scroll paints exactly as it would | ||
| 322 | + // outside one — nothing has to know it is being clipped. | ||
| 323 | + let content_h = self | ||
| 324 | + .tree | ||
| 325 | + .children(id) | ||
| 326 | + .iter() | ||
| 327 | + .map(|c| layout::height_for_width(self.tree, *c, area.w)) | ||
| 328 | + .sum::<u16>() | ||
| 329 | + .max(1); | ||
| 330 | + let max_offset = content_h.saturating_sub(area.h); | ||
| 331 | + let offset = props.cells("offset", 0).min(max_offset); | ||
| 332 | + self.out.scrolled.push((id, offset)); | ||
| 333 | + | ||
| 334 | + let mut buffer = Screen::new(area.w, content_h); | ||
| 335 | + let mut inner = Painter { | ||
| 336 | + tree: self.tree, | ||
| 337 | + screen: &mut buffer, | ||
| 338 | + focus: self.focus, | ||
| 339 | + caret: self.caret, | ||
| 340 | + tick: self.tick, | ||
| 341 | + out: Painted::default(), | ||
| 342 | + overlays: Vec::new(), | ||
| 343 | + }; | ||
| 344 | + let full = Rect::new(0, 0, area.w, content_h); | ||
| 345 | + inner.children(id, full, style, enabled); | ||
| 346 | + let learned = inner.out; | ||
| 347 | + | ||
| 348 | + for y in 0..area.h { | ||
| 349 | + for x in 0..area.w { | ||
| 350 | + if let Some(cell) = buffer.cell(x, y + offset) { | ||
| 351 | + self.screen.set(area.x + x, area.y + y, cell.ch, cell.style); | ||
| 352 | + } | ||
| 353 | + } | ||
| 354 | + } | ||
| 355 | + // Widgets inside keep their place in the focus ring; their rects move | ||
| 356 | + // by the viewport, and the ones scrolled out of sight take no clicks. | ||
| 357 | + self.out.ring.extend(learned.ring); | ||
| 358 | + for (node, rect) in learned.hits { | ||
| 359 | + if rect.y >= offset && rect.y < offset.saturating_add(area.h) { | ||
| 360 | + self.out.hits.push(( | ||
| 361 | + node, | ||
| 362 | + Rect::new( | ||
| 363 | + area.x + rect.x, | ||
| 364 | + area.y + rect.y - offset, | ||
| 365 | + rect.w, | ||
| 366 | + rect.h.min(area.h), | ||
| 367 | + ), | ||
| 368 | + )); | ||
| 369 | + } | ||
| 370 | + } | ||
| 371 | + self.out.scrolled.extend(learned.scrolled); | ||
| 372 | + if let Some((cx, cy)) = learned.cursor { | ||
| 373 | + if cy >= offset && cy < offset.saturating_add(area.h) { | ||
| 374 | + self.out.cursor = Some((area.x + cx, area.y + cy - offset)); | ||
| 375 | + } | ||
| 376 | + } | ||
| 377 | + } | ||
| 378 | + | ||
| 379 | + fn overlay(&mut self, id: u32, screen: Rect) { | ||
| 380 | + let props = self.tree.props(id); | ||
| 381 | + let w = layout::width(self.tree, id, false).min(screen.w); | ||
| 382 | + let h = layout::height_for_width(self.tree, id, w).min(screen.h); | ||
| 383 | + let (x, y) = ( | ||
| 384 | + screen.x + Align::Center.offset_pub(w, screen.w), | ||
| 385 | + screen.y + Align::Center.offset_pub(h, screen.h), | ||
| 386 | + ); | ||
| 387 | + let area = Rect::new(x, y, w, h); | ||
| 388 | + let style = self.style_for(&props, Style::default(), true); | ||
| 389 | + // Blank what is under it: a floating panel that shows the screen | ||
| 390 | + // through its gaps is unreadable. | ||
| 391 | + for row in area.y..area.y + area.h { | ||
| 392 | + for col in area.x..area.x + area.w { | ||
| 393 | + self.screen.set(col, row, ' ', style); | ||
| 394 | + } | ||
| 395 | + } | ||
| 396 | + self.border(area, props.label(), style); | ||
| 397 | + let pad = layout::inset(&Tag::Overlay, &props); | ||
| 398 | + self.children(id, area.shrink(pad), style, true); | ||
| 399 | + } | ||
| 400 | + | ||
| 401 | + /// A single-line box, with `label` set into the top edge when there is one. | ||
| 402 | + fn border(&mut self, area: Rect, label: &str, style: Style) { | ||
| 403 | + if area.w < 2 || area.h < 2 { | ||
| 404 | + return; | ||
| 405 | + } | ||
| 406 | + let (x1, y1) = (area.x + area.w - 1, area.y + area.h - 1); | ||
| 407 | + for x in area.x..=x1 { | ||
| 408 | + self.screen.set(x, area.y, '─', style); | ||
| 409 | + self.screen.set(x, y1, '─', style); | ||
| 410 | + } | ||
| 411 | + for y in area.y..=y1 { | ||
| 412 | + self.screen.set(area.x, y, '│', style); | ||
| 413 | + self.screen.set(x1, y, '│', style); | ||
| 414 | + } | ||
| 415 | + self.screen.set(area.x, area.y, '┌', style); | ||
| 416 | + self.screen.set(x1, area.y, '┐', style); | ||
| 417 | + self.screen.set(area.x, y1, '└', style); | ||
| 418 | + self.screen.set(x1, y1, '┘', style); | ||
| 419 | + if !label.is_empty() && area.w > 4 { | ||
| 420 | + let text = format!(" {label} "); | ||
| 421 | + self.screen.text( | ||
| 422 | + area.x + 1, | ||
| 423 | + area.y, | ||
| 424 | + area.w - 2, | ||
| 425 | + &text, | ||
| 426 | + style.with(attr::BOLD), | ||
| 427 | + ); | ||
| 428 | + } | ||
| 429 | + } | ||
| 430 | +} | ||
| 431 | + | ||
| 432 | +impl Align { | ||
| 433 | + /// [`Align::offset`] is private to the layout module; overlays are the one | ||
| 434 | + /// caller outside it that centres something by hand. | ||
| 435 | + fn offset_pub(self, size: u16, avail: u16) -> u16 { | ||
| 436 | + layout::place(self, size, avail).0 | ||
| 437 | + } | ||
| 438 | +} | ||
added
crates/jolt-tui/src/screen.rs +319 -0 | new file mode 100644 | ||
| @@ -0,0 +1,319 @@ | ||
| 1 | +//! A grid of styled cells, and the colours that go in it. | |
| 2 | +//! | |
| 3 | +//! Everything this crate paints goes here first, and only [`crate::term`] ever | |
| 4 | +//! turns it into escape sequences. That split is deliberate and is the same one | |
| 5 | +//! glimmer-tui makes on the jolt side: painting into a grid needs no terminal, | |
| 6 | +//! no raw mode and no TTY, so the whole widget layer is testable in a unit test | |
| 7 | +//! and in CI — `tui_headless` opens a screen and nothing else. | |
| 8 | + | |
| 9 | +/// A terminal colour, in the three ways a caller can write one. | |
| 10 | +/// | |
| 11 | +/// `Default` is not black: it is "whatever the terminal was using", which is | |
| 12 | +/// what a theme-respecting TUI wants for most of its surface. | |
| 13 | +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] | |
| 14 | +pub enum Color { | |
| 15 | + #[default] | |
| 16 | + Default, | |
| 17 | + /// An index into the xterm 256-colour palette. 0–15 are the named ones. | |
| 18 | + Indexed(u8), | |
| 19 | + Rgb(u8, u8, u8), | |
| 20 | +} | |
| 21 | + | |
| 22 | +impl Color { | |
| 23 | + /// Parse a colour prop: a name (`red`, `bright-blue`, `default`), a palette | |
| 24 | + /// index (`"33"`), a hex triple (`#ff6432` or `#f64`), or `r,g,b`. | |
| 25 | + /// | |
| 26 | + /// Answers `None` for anything else, which the caller reads as "leave the | |
| 27 | + /// colour alone" rather than as an error — a typo'd colour should not take | |
| 28 | + /// the widget away. | |
| 29 | + pub fn parse(text: &str) -> Option<Self> { | |
| 30 | + let text = text.trim(); | |
| 31 | + if text.is_empty() { | |
| 32 | + return None; | |
| 33 | + } | |
| 34 | + if let Some(hex) = text.strip_prefix('#') { | |
| 35 | + return Self::from_hex(hex); | |
| 36 | + } | |
| 37 | + if let Ok(index) = text.parse::<u8>() { | |
| 38 | + return Some(Self::Indexed(index)); | |
| 39 | + } | |
| 40 | + if text.contains(',') { | |
| 41 | + let parts: Vec<&str> = text.split(',').map(str::trim).collect(); | |
| 42 | + if let [r, g, b] = parts[..] { | |
| 43 | + return Some(Self::Rgb(r.parse().ok()?, g.parse().ok()?, b.parse().ok()?)); | |
| 44 | + } | |
| 45 | + return None; | |
| 46 | + } | |
| 47 | + let (name, bright) = match text.strip_prefix("bright-") { | |
| 48 | + Some(rest) => (rest, true), | |
| 49 | + None => (text, false), | |
| 50 | + }; | |
| 51 | + let base = match name { | |
| 52 | + "black" => 0, | |
| 53 | + "red" => 1, | |
| 54 | + "green" => 2, | |
| 55 | + "yellow" => 3, | |
| 56 | + "blue" => 4, | |
| 57 | + "magenta" => 5, | |
| 58 | + "cyan" => 6, | |
| 59 | + "white" => 7, | |
| 60 | + "default" if !bright => return Some(Self::Default), | |
| 61 | + _ => return None, | |
| 62 | + }; | |
| 63 | + Some(Self::Indexed(base + if bright { 8 } else { 0 })) | |
| 64 | + } | |
| 65 | + | |
| 66 | + fn from_hex(hex: &str) -> Option<Self> { | |
| 67 | + let bytes = hex.as_bytes(); | |
| 68 | + let nib = |c: u8| (c as char).to_digit(16).map(|d| d as u8); | |
| 69 | + match bytes.len() { | |
| 70 | + 3 => { | |
| 71 | + let (r, g, b) = (nib(bytes[0])?, nib(bytes[1])?, nib(bytes[2])?); | |
| 72 | + Some(Self::Rgb(r * 17, g * 17, b * 17)) | |
| 73 | + } | |
| 74 | + 6 => { | |
| 75 | + let pair = |i: usize| Some(nib(bytes[i])? * 16 + nib(bytes[i + 1])?); | |
| 76 | + Some(Self::Rgb(pair(0)?, pair(2)?, pair(4)?)) | |
| 77 | + } | |
| 78 | + _ => None, | |
| 79 | + } | |
| 80 | + } | |
| 81 | +} | |
| 82 | + | |
| 83 | +/// The attribute bits a cell can carry. A `u8` rather than a set of `bool`s | |
| 84 | +/// because a cell is copied a great many times a frame. | |
| 85 | +pub mod attr { | |
| 86 | + pub const BOLD: u8 = 1 << 0; | |
| 87 | + pub const DIM: u8 = 1 << 1; | |
| 88 | + pub const UNDERLINE: u8 = 1 << 2; | |
| 89 | + pub const REVERSE: u8 = 1 << 3; | |
| 90 | + pub const BLINK: u8 = 1 << 4; | |
| 91 | + pub const ITALIC: u8 = 1 << 5; | |
| 92 | +} | |
| 93 | + | |
| 94 | +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] | |
| 95 | +pub struct Style { | |
| 96 | + pub fg: Color, | |
| 97 | + pub bg: Color, | |
| 98 | + pub attrs: u8, | |
| 99 | +} | |
| 100 | + | |
| 101 | +impl Style { | |
| 102 | + pub fn with(mut self, bits: u8) -> Self { | |
| 103 | + self.attrs |= bits; | |
| 104 | + self | |
| 105 | + } | |
| 106 | + | |
| 107 | + pub fn fg(mut self, color: Color) -> Self { | |
| 108 | + self.fg = color; | |
| 109 | + self | |
| 110 | + } | |
| 111 | + | |
| 112 | + pub fn has(&self, bits: u8) -> bool { | |
| 113 | + self.attrs & bits != 0 | |
| 114 | + } | |
| 115 | +} | |
| 116 | + | |
| 117 | +#[derive(Clone, Copy, Debug, PartialEq, Eq)] | |
| 118 | +pub struct Cell { | |
| 119 | + pub ch: char, | |
| 120 | + pub style: Style, | |
| 121 | +} | |
| 122 | + | |
| 123 | +impl Default for Cell { | |
| 124 | + fn default() -> Self { | |
| 125 | + Self { | |
| 126 | + ch: ' ', | |
| 127 | + style: Style::default(), | |
| 128 | + } | |
| 129 | + } | |
| 130 | +} | |
| 131 | + | |
| 132 | +/// A rectangle in cells. Columns and rows, origin top left. | |
| 133 | +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] | |
| 134 | +pub struct Rect { | |
| 135 | + pub x: u16, | |
| 136 | + pub y: u16, | |
| 137 | + pub w: u16, | |
| 138 | + pub h: u16, | |
| 139 | +} | |
| 140 | + | |
| 141 | +impl Rect { | |
| 142 | + pub fn new(x: u16, y: u16, w: u16, h: u16) -> Self { | |
| 143 | + Self { x, y, w, h } | |
| 144 | + } | |
| 145 | + | |
| 146 | + pub fn is_empty(&self) -> bool { | |
| 147 | + self.w == 0 || self.h == 0 | |
| 148 | + } | |
| 149 | + | |
| 150 | + pub fn contains(&self, x: u16, y: u16) -> bool { | |
| 151 | + x >= self.x && x < self.x + self.w && y >= self.y && y < self.y + self.h | |
| 152 | + } | |
| 153 | + | |
| 154 | + /// The rect left after taking `n` cells off every side. Saturating, so | |
| 155 | + /// padding larger than the rect answers an empty one rather than wrapping. | |
| 156 | + pub fn shrink(&self, n: u16) -> Self { | |
| 157 | + let take = n.saturating_mul(2); | |
| 158 | + Self { | |
| 159 | + x: self.x.saturating_add(n), | |
| 160 | + y: self.y.saturating_add(n), | |
| 161 | + w: self.w.saturating_sub(take), | |
| 162 | + h: self.h.saturating_sub(take), | |
| 163 | + } | |
| 164 | + } | |
| 165 | +} | |
| 166 | + | |
| 167 | +#[derive(Clone, Debug)] | |
| 168 | +pub struct Screen { | |
| 169 | + w: u16, | |
| 170 | + h: u16, | |
| 171 | + cells: Vec<Cell>, | |
| 172 | +} | |
| 173 | + | |
| 174 | +impl Screen { | |
| 175 | + pub fn new(w: u16, h: u16) -> Self { | |
| 176 | + Self { | |
| 177 | + w, | |
| 178 | + h, | |
| 179 | + cells: vec![Cell::default(); w as usize * h as usize], | |
| 180 | + } | |
| 181 | + } | |
| 182 | + | |
| 183 | + pub fn width(&self) -> u16 { | |
| 184 | + self.w | |
| 185 | + } | |
| 186 | + | |
| 187 | + pub fn height(&self) -> u16 { | |
| 188 | + self.h | |
| 189 | + } | |
| 190 | + | |
| 191 | + pub fn rect(&self) -> Rect { | |
| 192 | + Rect::new(0, 0, self.w, self.h) | |
| 193 | + } | |
| 194 | + | |
| 195 | + pub fn resize(&mut self, w: u16, h: u16) { | |
| 196 | + if (w, h) != (self.w, self.h) { | |
| 197 | + *self = Self::new(w, h); | |
| 198 | + } | |
| 199 | + } | |
| 200 | + | |
| 201 | + pub fn clear(&mut self) { | |
| 202 | + self.cells.fill(Cell::default()); | |
| 203 | + } | |
| 204 | + | |
| 205 | + pub fn cell(&self, x: u16, y: u16) -> Option<&Cell> { | |
| 206 | + if x < self.w && y < self.h { | |
| 207 | + self.cells.get(y as usize * self.w as usize + x as usize) | |
| 208 | + } else { | |
| 209 | + None | |
| 210 | + } | |
| 211 | + } | |
| 212 | + | |
| 213 | + pub fn set(&mut self, x: u16, y: u16, ch: char, style: Style) { | |
| 214 | + if x < self.w && y < self.h { | |
| 215 | + let i = y as usize * self.w as usize + x as usize; | |
| 216 | + self.cells[i] = Cell { ch, style }; | |
| 217 | + } | |
| 218 | + } | |
| 219 | + | |
| 220 | + /// Write `text` at `x, y`, clipped to `width` columns. Answers how many | |
| 221 | + /// columns were used. | |
| 222 | + pub fn text(&mut self, x: u16, y: u16, width: u16, text: &str, style: Style) -> u16 { | |
| 223 | + let mut col = 0u16; | |
| 224 | + for ch in text.chars() { | |
| 225 | + if col >= width { | |
| 226 | + break; | |
| 227 | + } | |
| 228 | + // A control character in a label would move the cursor; show it as | |
| 229 | + // a dot instead of letting it rearrange the screen. | |
| 230 | + let ch = if (ch as u32) < 0x20 { '·' } else { ch }; | |
| 231 | + self.set(x.saturating_add(col), y, ch, style); | |
| 232 | + col += 1; | |
| 233 | + } | |
| 234 | + col | |
| 235 | + } | |
| 236 | + | |
| 237 | + /// Paint every cell of `rect` with `style`, keeping the characters — that | |
| 238 | + /// is what a background is: a colour behind whatever is already there. | |
| 239 | + pub fn fill(&mut self, rect: Rect, style: Style) { | |
| 240 | + for y in rect.y..rect.y.saturating_add(rect.h) { | |
| 241 | + for x in rect.x..rect.x.saturating_add(rect.w) { | |
| 242 | + if x < self.w && y < self.h { | |
| 243 | + let i = y as usize * self.w as usize + x as usize; | |
| 244 | + let ch = self.cells[i].ch; | |
| 245 | + self.cells[i] = Cell { ch, style }; | |
| 246 | + } | |
| 247 | + } | |
| 248 | + } | |
| 249 | + } | |
| 250 | + | |
| 251 | + /// One row as text, trailing blanks trimmed. The whole reason the grid is | |
| 252 | + /// addressable: a test asserts on lines, not on escape sequences. | |
| 253 | + pub fn line(&self, y: u16) -> String { | |
| 254 | + if y >= self.h { | |
| 255 | + return String::new(); | |
| 256 | + } | |
| 257 | + let start = y as usize * self.w as usize; | |
| 258 | + let row: String = self.cells[start..start + self.w as usize] | |
| 259 | + .iter() | |
| 260 | + .map(|c| c.ch) | |
| 261 | + .collect(); | |
| 262 | + row.trim_end().to_owned() | |
| 263 | + } | |
| 264 | + | |
| 265 | + #[cfg(test)] | |
| 266 | + pub fn lines(&self) -> Vec<String> { | |
| 267 | + (0..self.h).map(|y| self.line(y)).collect() | |
| 268 | + } | |
| 269 | + | |
| 270 | + pub(crate) fn cells(&self) -> &[Cell] { | |
| 271 | + &self.cells | |
| 272 | + } | |
| 273 | +} | |
| 274 | + | |
| 275 | +#[cfg(test)] | |
| 276 | +mod tests { | |
| 277 | + use super::*; | |
| 278 | + | |
| 279 | + #[test] | |
| 280 | + fn colours_parse_in_every_shape_a_caller_writes_them() { | |
| 281 | + assert_eq!(Color::parse("red"), Some(Color::Indexed(1))); | |
| 282 | + assert_eq!(Color::parse("bright-blue"), Some(Color::Indexed(12))); | |
| 283 | + assert_eq!(Color::parse("default"), Some(Color::Default)); | |
| 284 | + assert_eq!(Color::parse("33"), Some(Color::Indexed(33))); | |
| 285 | + assert_eq!(Color::parse("#ff6432"), Some(Color::Rgb(255, 100, 50))); | |
| 286 | + assert_eq!(Color::parse("#f64"), Some(Color::Rgb(255, 102, 68))); | |
| 287 | + assert_eq!(Color::parse("255,100,50"), Some(Color::Rgb(255, 100, 50))); | |
| 288 | + } | |
| 289 | + | |
| 290 | + #[test] | |
| 291 | + fn an_unreadable_colour_is_no_colour_rather_than_an_error() { | |
| 292 | + assert_eq!(Color::parse("puce"), None); | |
| 293 | + assert_eq!(Color::parse("#gg0000"), None); | |
| 294 | + assert_eq!(Color::parse(""), None); | |
| 295 | + } | |
| 296 | + | |
| 297 | + #[test] | |
| 298 | + fn text_clips_to_the_width_it_was_given() { | |
| 299 | + let mut screen = Screen::new(10, 2); | |
| 300 | + screen.text(0, 0, 4, "abcdefg", Style::default()); | |
| 301 | + assert_eq!(screen.line(0), "abcd"); | |
| 302 | + } | |
| 303 | + | |
| 304 | + #[test] | |
| 305 | + fn a_control_character_cannot_move_the_cursor() { | |
| 306 | + let mut screen = Screen::new(6, 1); | |
| 307 | + screen.text(0, 0, 6, "a\rb", Style::default()); | |
| 308 | + assert_eq!(screen.line(0), "a·b"); | |
| 309 | + } | |
| 310 | + | |
| 311 | + #[test] | |
| 312 | + fn filling_a_rect_keeps_the_characters_under_it() { | |
| 313 | + let mut screen = Screen::new(4, 1); | |
| 314 | + screen.text(0, 0, 4, "hi", Style::default()); | |
| 315 | + screen.fill(screen.rect(), Style::default().with(attr::REVERSE)); | |
| 316 | + assert_eq!(screen.line(0), "hi"); | |
| 317 | + assert!(screen.cell(0, 0).unwrap().style.has(attr::REVERSE)); | |
| 318 | + } | |
| 319 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,319 @@ | |||
| 1 | +//! A grid of styled cells, and the colours that go in it. | ||
| 2 | +//! | ||
| 3 | +//! Everything this crate paints goes here first, and only [`crate::term`] ever | ||
| 4 | +//! turns it into escape sequences. That split is deliberate and is the same one | ||
| 5 | +//! glimmer-tui makes on the jolt side: painting into a grid needs no terminal, | ||
| 6 | +//! no raw mode and no TTY, so the whole widget layer is testable in a unit test | ||
| 7 | +//! and in CI — `tui_headless` opens a screen and nothing else. | ||
| 8 | + | ||
| 9 | +/// A terminal colour, in the three ways a caller can write one. | ||
| 10 | +/// | ||
| 11 | +/// `Default` is not black: it is "whatever the terminal was using", which is | ||
| 12 | +/// what a theme-respecting TUI wants for most of its surface. | ||
| 13 | +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] | ||
| 14 | +pub enum Color { | ||
| 15 | + #[default] | ||
| 16 | + Default, | ||
| 17 | + /// An index into the xterm 256-colour palette. 0–15 are the named ones. | ||
| 18 | + Indexed(u8), | ||
| 19 | + Rgb(u8, u8, u8), | ||
| 20 | +} | ||
| 21 | + | ||
| 22 | +impl Color { | ||
| 23 | + /// Parse a colour prop: a name (`red`, `bright-blue`, `default`), a palette | ||
| 24 | + /// index (`"33"`), a hex triple (`#ff6432` or `#f64`), or `r,g,b`. | ||
| 25 | + /// | ||
| 26 | + /// Answers `None` for anything else, which the caller reads as "leave the | ||
| 27 | + /// colour alone" rather than as an error — a typo'd colour should not take | ||
| 28 | + /// the widget away. | ||
| 29 | + pub fn parse(text: &str) -> Option<Self> { | ||
| 30 | + let text = text.trim(); | ||
| 31 | + if text.is_empty() { | ||
| 32 | + return None; | ||
| 33 | + } | ||
| 34 | + if let Some(hex) = text.strip_prefix('#') { | ||
| 35 | + return Self::from_hex(hex); | ||
| 36 | + } | ||
| 37 | + if let Ok(index) = text.parse::<u8>() { | ||
| 38 | + return Some(Self::Indexed(index)); | ||
| 39 | + } | ||
| 40 | + if text.contains(',') { | ||
| 41 | + let parts: Vec<&str> = text.split(',').map(str::trim).collect(); | ||
| 42 | + if let [r, g, b] = parts[..] { | ||
| 43 | + return Some(Self::Rgb(r.parse().ok()?, g.parse().ok()?, b.parse().ok()?)); | ||
| 44 | + } | ||
| 45 | + return None; | ||
| 46 | + } | ||
| 47 | + let (name, bright) = match text.strip_prefix("bright-") { | ||
| 48 | + Some(rest) => (rest, true), | ||
| 49 | + None => (text, false), | ||
| 50 | + }; | ||
| 51 | + let base = match name { | ||
| 52 | + "black" => 0, | ||
| 53 | + "red" => 1, | ||
| 54 | + "green" => 2, | ||
| 55 | + "yellow" => 3, | ||
| 56 | + "blue" => 4, | ||
| 57 | + "magenta" => 5, | ||
| 58 | + "cyan" => 6, | ||
| 59 | + "white" => 7, | ||
| 60 | + "default" if !bright => return Some(Self::Default), | ||
| 61 | + _ => return None, | ||
| 62 | + }; | ||
| 63 | + Some(Self::Indexed(base + if bright { 8 } else { 0 })) | ||
| 64 | + } | ||
| 65 | + | ||
| 66 | + fn from_hex(hex: &str) -> Option<Self> { | ||
| 67 | + let bytes = hex.as_bytes(); | ||
| 68 | + let nib = |c: u8| (c as char).to_digit(16).map(|d| d as u8); | ||
| 69 | + match bytes.len() { | ||
| 70 | + 3 => { | ||
| 71 | + let (r, g, b) = (nib(bytes[0])?, nib(bytes[1])?, nib(bytes[2])?); | ||
| 72 | + Some(Self::Rgb(r * 17, g * 17, b * 17)) | ||
| 73 | + } | ||
| 74 | + 6 => { | ||
| 75 | + let pair = |i: usize| Some(nib(bytes[i])? * 16 + nib(bytes[i + 1])?); | ||
| 76 | + Some(Self::Rgb(pair(0)?, pair(2)?, pair(4)?)) | ||
| 77 | + } | ||
| 78 | + _ => None, | ||
| 79 | + } | ||
| 80 | + } | ||
| 81 | +} | ||
| 82 | + | ||
| 83 | +/// The attribute bits a cell can carry. A `u8` rather than a set of `bool`s | ||
| 84 | +/// because a cell is copied a great many times a frame. | ||
| 85 | +pub mod attr { | ||
| 86 | + pub const BOLD: u8 = 1 << 0; | ||
| 87 | + pub const DIM: u8 = 1 << 1; | ||
| 88 | + pub const UNDERLINE: u8 = 1 << 2; | ||
| 89 | + pub const REVERSE: u8 = 1 << 3; | ||
| 90 | + pub const BLINK: u8 = 1 << 4; | ||
| 91 | + pub const ITALIC: u8 = 1 << 5; | ||
| 92 | +} | ||
| 93 | + | ||
| 94 | +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] | ||
| 95 | +pub struct Style { | ||
| 96 | + pub fg: Color, | ||
| 97 | + pub bg: Color, | ||
| 98 | + pub attrs: u8, | ||
| 99 | +} | ||
| 100 | + | ||
| 101 | +impl Style { | ||
| 102 | + pub fn with(mut self, bits: u8) -> Self { | ||
| 103 | + self.attrs |= bits; | ||
| 104 | + self | ||
| 105 | + } | ||
| 106 | + | ||
| 107 | + pub fn fg(mut self, color: Color) -> Self { | ||
| 108 | + self.fg = color; | ||
| 109 | + self | ||
| 110 | + } | ||
| 111 | + | ||
| 112 | + pub fn has(&self, bits: u8) -> bool { | ||
| 113 | + self.attrs & bits != 0 | ||
| 114 | + } | ||
| 115 | +} | ||
| 116 | + | ||
| 117 | +#[derive(Clone, Copy, Debug, PartialEq, Eq)] | ||
| 118 | +pub struct Cell { | ||
| 119 | + pub ch: char, | ||
| 120 | + pub style: Style, | ||
| 121 | +} | ||
| 122 | + | ||
| 123 | +impl Default for Cell { | ||
| 124 | + fn default() -> Self { | ||
| 125 | + Self { | ||
| 126 | + ch: ' ', | ||
| 127 | + style: Style::default(), | ||
| 128 | + } | ||
| 129 | + } | ||
| 130 | +} | ||
| 131 | + | ||
| 132 | +/// A rectangle in cells. Columns and rows, origin top left. | ||
| 133 | +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] | ||
| 134 | +pub struct Rect { | ||
| 135 | + pub x: u16, | ||
| 136 | + pub y: u16, | ||
| 137 | + pub w: u16, | ||
| 138 | + pub h: u16, | ||
| 139 | +} | ||
| 140 | + | ||
| 141 | +impl Rect { | ||
| 142 | + pub fn new(x: u16, y: u16, w: u16, h: u16) -> Self { | ||
| 143 | + Self { x, y, w, h } | ||
| 144 | + } | ||
| 145 | + | ||
| 146 | + pub fn is_empty(&self) -> bool { | ||
| 147 | + self.w == 0 || self.h == 0 | ||
| 148 | + } | ||
| 149 | + | ||
| 150 | + pub fn contains(&self, x: u16, y: u16) -> bool { | ||
| 151 | + x >= self.x && x < self.x + self.w && y >= self.y && y < self.y + self.h | ||
| 152 | + } | ||
| 153 | + | ||
| 154 | + /// The rect left after taking `n` cells off every side. Saturating, so | ||
| 155 | + /// padding larger than the rect answers an empty one rather than wrapping. | ||
| 156 | + pub fn shrink(&self, n: u16) -> Self { | ||
| 157 | + let take = n.saturating_mul(2); | ||
| 158 | + Self { | ||
| 159 | + x: self.x.saturating_add(n), | ||
| 160 | + y: self.y.saturating_add(n), | ||
| 161 | + w: self.w.saturating_sub(take), | ||
| 162 | + h: self.h.saturating_sub(take), | ||
| 163 | + } | ||
| 164 | + } | ||
| 165 | +} | ||
| 166 | + | ||
| 167 | +#[derive(Clone, Debug)] | ||
| 168 | +pub struct Screen { | ||
| 169 | + w: u16, | ||
| 170 | + h: u16, | ||
| 171 | + cells: Vec<Cell>, | ||
| 172 | +} | ||
| 173 | + | ||
| 174 | +impl Screen { | ||
| 175 | + pub fn new(w: u16, h: u16) -> Self { | ||
| 176 | + Self { | ||
| 177 | + w, | ||
| 178 | + h, | ||
| 179 | + cells: vec![Cell::default(); w as usize * h as usize], | ||
| 180 | + } | ||
| 181 | + } | ||
| 182 | + | ||
| 183 | + pub fn width(&self) -> u16 { | ||
| 184 | + self.w | ||
| 185 | + } | ||
| 186 | + | ||
| 187 | + pub fn height(&self) -> u16 { | ||
| 188 | + self.h | ||
| 189 | + } | ||
| 190 | + | ||
| 191 | + pub fn rect(&self) -> Rect { | ||
| 192 | + Rect::new(0, 0, self.w, self.h) | ||
| 193 | + } | ||
| 194 | + | ||
| 195 | + pub fn resize(&mut self, w: u16, h: u16) { | ||
| 196 | + if (w, h) != (self.w, self.h) { | ||
| 197 | + *self = Self::new(w, h); | ||
| 198 | + } | ||
| 199 | + } | ||
| 200 | + | ||
| 201 | + pub fn clear(&mut self) { | ||
| 202 | + self.cells.fill(Cell::default()); | ||
| 203 | + } | ||
| 204 | + | ||
| 205 | + pub fn cell(&self, x: u16, y: u16) -> Option<&Cell> { | ||
| 206 | + if x < self.w && y < self.h { | ||
| 207 | + self.cells.get(y as usize * self.w as usize + x as usize) | ||
| 208 | + } else { | ||
| 209 | + None | ||
| 210 | + } | ||
| 211 | + } | ||
| 212 | + | ||
| 213 | + pub fn set(&mut self, x: u16, y: u16, ch: char, style: Style) { | ||
| 214 | + if x < self.w && y < self.h { | ||
| 215 | + let i = y as usize * self.w as usize + x as usize; | ||
| 216 | + self.cells[i] = Cell { ch, style }; | ||
| 217 | + } | ||
| 218 | + } | ||
| 219 | + | ||
| 220 | + /// Write `text` at `x, y`, clipped to `width` columns. Answers how many | ||
| 221 | + /// columns were used. | ||
| 222 | + pub fn text(&mut self, x: u16, y: u16, width: u16, text: &str, style: Style) -> u16 { | ||
| 223 | + let mut col = 0u16; | ||
| 224 | + for ch in text.chars() { | ||
| 225 | + if col >= width { | ||
| 226 | + break; | ||
| 227 | + } | ||
| 228 | + // A control character in a label would move the cursor; show it as | ||
| 229 | + // a dot instead of letting it rearrange the screen. | ||
| 230 | + let ch = if (ch as u32) < 0x20 { '·' } else { ch }; | ||
| 231 | + self.set(x.saturating_add(col), y, ch, style); | ||
| 232 | + col += 1; | ||
| 233 | + } | ||
| 234 | + col | ||
| 235 | + } | ||
| 236 | + | ||
| 237 | + /// Paint every cell of `rect` with `style`, keeping the characters — that | ||
| 238 | + /// is what a background is: a colour behind whatever is already there. | ||
| 239 | + pub fn fill(&mut self, rect: Rect, style: Style) { | ||
| 240 | + for y in rect.y..rect.y.saturating_add(rect.h) { | ||
| 241 | + for x in rect.x..rect.x.saturating_add(rect.w) { | ||
| 242 | + if x < self.w && y < self.h { | ||
| 243 | + let i = y as usize * self.w as usize + x as usize; | ||
| 244 | + let ch = self.cells[i].ch; | ||
| 245 | + self.cells[i] = Cell { ch, style }; | ||
| 246 | + } | ||
| 247 | + } | ||
| 248 | + } | ||
| 249 | + } | ||
| 250 | + | ||
| 251 | + /// One row as text, trailing blanks trimmed. The whole reason the grid is | ||
| 252 | + /// addressable: a test asserts on lines, not on escape sequences. | ||
| 253 | + pub fn line(&self, y: u16) -> String { | ||
| 254 | + if y >= self.h { | ||
| 255 | + return String::new(); | ||
| 256 | + } | ||
| 257 | + let start = y as usize * self.w as usize; | ||
| 258 | + let row: String = self.cells[start..start + self.w as usize] | ||
| 259 | + .iter() | ||
| 260 | + .map(|c| c.ch) | ||
| 261 | + .collect(); | ||
| 262 | + row.trim_end().to_owned() | ||
| 263 | + } | ||
| 264 | + | ||
| 265 | + #[cfg(test)] | ||
| 266 | + pub fn lines(&self) -> Vec<String> { | ||
| 267 | + (0..self.h).map(|y| self.line(y)).collect() | ||
| 268 | + } | ||
| 269 | + | ||
| 270 | + pub(crate) fn cells(&self) -> &[Cell] { | ||
| 271 | + &self.cells | ||
| 272 | + } | ||
| 273 | +} | ||
| 274 | + | ||
| 275 | +#[cfg(test)] | ||
| 276 | +mod tests { | ||
| 277 | + use super::*; | ||
| 278 | + | ||
| 279 | + #[test] | ||
| 280 | + fn colours_parse_in_every_shape_a_caller_writes_them() { | ||
| 281 | + assert_eq!(Color::parse("red"), Some(Color::Indexed(1))); | ||
| 282 | + assert_eq!(Color::parse("bright-blue"), Some(Color::Indexed(12))); | ||
| 283 | + assert_eq!(Color::parse("default"), Some(Color::Default)); | ||
| 284 | + assert_eq!(Color::parse("33"), Some(Color::Indexed(33))); | ||
| 285 | + assert_eq!(Color::parse("#ff6432"), Some(Color::Rgb(255, 100, 50))); | ||
| 286 | + assert_eq!(Color::parse("#f64"), Some(Color::Rgb(255, 102, 68))); | ||
| 287 | + assert_eq!(Color::parse("255,100,50"), Some(Color::Rgb(255, 100, 50))); | ||
| 288 | + } | ||
| 289 | + | ||
| 290 | + #[test] | ||
| 291 | + fn an_unreadable_colour_is_no_colour_rather_than_an_error() { | ||
| 292 | + assert_eq!(Color::parse("puce"), None); | ||
| 293 | + assert_eq!(Color::parse("#gg0000"), None); | ||
| 294 | + assert_eq!(Color::parse(""), None); | ||
| 295 | + } | ||
| 296 | + | ||
| 297 | + #[test] | ||
| 298 | + fn text_clips_to_the_width_it_was_given() { | ||
| 299 | + let mut screen = Screen::new(10, 2); | ||
| 300 | + screen.text(0, 0, 4, "abcdefg", Style::default()); | ||
| 301 | + assert_eq!(screen.line(0), "abcd"); | ||
| 302 | + } | ||
| 303 | + | ||
| 304 | + #[test] | ||
| 305 | + fn a_control_character_cannot_move_the_cursor() { | ||
| 306 | + let mut screen = Screen::new(6, 1); | ||
| 307 | + screen.text(0, 0, 6, "a\rb", Style::default()); | ||
| 308 | + assert_eq!(screen.line(0), "a·b"); | ||
| 309 | + } | ||
| 310 | + | ||
| 311 | + #[test] | ||
| 312 | + fn filling_a_rect_keeps_the_characters_under_it() { | ||
| 313 | + let mut screen = Screen::new(4, 1); | ||
| 314 | + screen.text(0, 0, 4, "hi", Style::default()); | ||
| 315 | + screen.fill(screen.rect(), Style::default().with(attr::REVERSE)); | ||
| 316 | + assert_eq!(screen.line(0), "hi"); | ||
| 317 | + assert!(screen.cell(0, 0).unwrap().style.has(attr::REVERSE)); | ||
| 318 | + } | ||
| 319 | +} | ||
added
crates/jolt-tui/src/term.rs +190 -0 | new file mode 100644 | ||
| @@ -0,0 +1,190 @@ | ||
| 1 | +//! The terminal itself: raw mode, the alternate screen, and the diff that | |
| 2 | +//! turns a grid of cells into the fewest escape sequences that will do. | |
| 3 | +//! | |
| 4 | +//! Everything above this file paints into a [`Screen`] and never writes a byte, | |
| 5 | +//! which is what makes the widget layer testable. This is the one place that | |
| 6 | +//! knows a TTY exists, and it is behind the `terminal` feature so a build for a | |
| 7 | +//! machine with no terminal crate at all still has the tree and the layout. | |
| 8 | + | |
| 9 | +use std::io::{self, Stdout, Write}; | |
| 10 | + | |
| 11 | +use crossterm::event::{ | |
| 12 | + DisableMouseCapture, EnableMouseCapture, Event, KeyEventKind, MouseButton, MouseEventKind, | |
| 13 | +}; | |
| 14 | +use crossterm::terminal::{ | |
| 15 | + disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen, | |
| 16 | +}; | |
| 17 | +use crossterm::{cursor, execute, queue, style}; | |
| 18 | + | |
| 19 | +use crate::keys; | |
| 20 | +use crate::screen::{attr, Color, Screen, Style}; | |
| 21 | + | |
| 22 | +/// One thing that happened, in the vocabulary [`crate::ui::Ui`] takes. | |
| 23 | +#[derive(Clone, Debug, PartialEq, Eq)] | |
| 24 | +pub enum Input { | |
| 25 | + Key(String), | |
| 26 | + Click(u16, u16), | |
| 27 | + Wheel(u16, u16, i32), | |
| 28 | + Resize(u16, u16), | |
| 29 | +} | |
| 30 | + | |
| 31 | +pub struct Term { | |
| 32 | + out: Stdout, | |
| 33 | + /// What is on the screen now, so a frame only sends what changed. | |
| 34 | + last: Screen, | |
| 35 | + mouse: bool, | |
| 36 | +} | |
| 37 | + | |
| 38 | +impl Term { | |
| 39 | + /// Take the terminal: raw mode, the alternate screen, mouse reporting, and | |
| 40 | + /// no cursor until a focused entry asks for one. | |
| 41 | + pub fn open(mouse: bool) -> io::Result<Self> { | |
| 42 | + let (w, h) = crossterm::terminal::size().unwrap_or((80, 24)); | |
| 43 | + let mut out = io::stdout(); | |
| 44 | + enable_raw_mode()?; | |
| 45 | + execute!(out, EnterAlternateScreen, cursor::Hide)?; | |
| 46 | + if mouse { | |
| 47 | + execute!(out, EnableMouseCapture)?; | |
| 48 | + } | |
| 49 | + Ok(Self { | |
| 50 | + out, | |
| 51 | + last: Screen::new(w, h), | |
| 52 | + mouse, | |
| 53 | + }) | |
| 54 | + } | |
| 55 | + | |
| 56 | + pub fn size(&self) -> (u16, u16) { | |
| 57 | + crossterm::terminal::size().unwrap_or((80, 24)) | |
| 58 | + } | |
| 59 | + | |
| 60 | + /// Give the terminal back. Called from `tui_close`, and again from a panic | |
| 61 | + /// hook — leaving a shell in raw mode with no cursor is the one failure a | |
| 62 | + /// TUI must not have. | |
| 63 | + pub fn close(&mut self) { | |
| 64 | + if self.mouse { | |
| 65 | + let _ = execute!(self.out, DisableMouseCapture); | |
| 66 | + } | |
| 67 | + let _ = execute!(self.out, cursor::Show, LeaveAlternateScreen); | |
| 68 | + let _ = disable_raw_mode(); | |
| 69 | + } | |
| 70 | + | |
| 71 | + /// Wait up to `timeout_ms` for input and answer everything that had | |
| 72 | + /// arrived. A zero timeout is a poll. | |
| 73 | + pub fn poll(&mut self, timeout_ms: u64) -> Vec<Input> { | |
| 74 | + let mut out = Vec::new(); | |
| 75 | + let deadline = std::time::Duration::from_millis(timeout_ms); | |
| 76 | + if !crossterm::event::poll(deadline).unwrap_or(false) { | |
| 77 | + return out; | |
| 78 | + } | |
| 79 | + // Drain what is queued rather than one event a call: a held arrow key | |
| 80 | + // or a paste arrives as a burst, and handling one per frame would make | |
| 81 | + // the UI lag behind the keyboard. | |
| 82 | + loop { | |
| 83 | + match crossterm::event::read() { | |
| 84 | + Ok(Event::Key(key)) => { | |
| 85 | + // Windows reports both press and release; a release would | |
| 86 | + // type every character twice. | |
| 87 | + if key.kind != KeyEventKind::Release { | |
| 88 | + if let Some(name) = keys::name(key) { | |
| 89 | + out.push(Input::Key(name)); | |
| 90 | + } | |
| 91 | + } | |
| 92 | + } | |
| 93 | + Ok(Event::Resize(w, h)) => out.push(Input::Resize(w, h)), | |
| 94 | + Ok(Event::Mouse(mouse)) => match mouse.kind { | |
| 95 | + MouseEventKind::Down(MouseButton::Left) => { | |
| 96 | + out.push(Input::Click(mouse.column, mouse.row)) | |
| 97 | + } | |
| 98 | + MouseEventKind::ScrollDown => { | |
| 99 | + out.push(Input::Wheel(mouse.column, mouse.row, 1)) | |
| 100 | + } | |
| 101 | + MouseEventKind::ScrollUp => out.push(Input::Wheel(mouse.column, mouse.row, -1)), | |
| 102 | + _ => {} | |
| 103 | + }, | |
| 104 | + Ok(_) => {} | |
| 105 | + Err(_) => break, | |
| 106 | + } | |
| 107 | + if !crossterm::event::poll(std::time::Duration::ZERO).unwrap_or(false) { | |
| 108 | + break; | |
| 109 | + } | |
| 110 | + } | |
| 111 | + out | |
| 112 | + } | |
| 113 | + | |
| 114 | + /// Send whatever differs between `screen` and what is already up there. | |
| 115 | + pub fn flush(&mut self, screen: &Screen, cursor: Option<(u16, u16)>) -> io::Result<()> { | |
| 116 | + if (self.last.width(), self.last.height()) != (screen.width(), screen.height()) { | |
| 117 | + self.last = Screen::new(screen.width(), screen.height()); | |
| 118 | + queue!( | |
| 119 | + self.out, | |
| 120 | + crossterm::terminal::Clear(crossterm::terminal::ClearType::All) | |
| 121 | + )?; | |
| 122 | + } | |
| 123 | + let mut style = None; | |
| 124 | + let mut at: Option<(u16, u16)> = None; | |
| 125 | + let width = screen.width(); | |
| 126 | + for (i, cell) in screen.cells().iter().enumerate() { | |
| 127 | + let (x, y) = ((i as u16) % width, (i as u16) / width); | |
| 128 | + if self.last.cell(x, y) == Some(cell) { | |
| 129 | + continue; | |
| 130 | + } | |
| 131 | + // Only move when the run breaks: a full-width change is one seek | |
| 132 | + // and a line of text, not a seek a cell. | |
| 133 | + if at != Some((x, y)) { | |
| 134 | + queue!(self.out, cursor::MoveTo(x, y))?; | |
| 135 | + } | |
| 136 | + if style != Some(cell.style) { | |
| 137 | + write_style(&mut self.out, cell.style)?; | |
| 138 | + style = Some(cell.style); | |
| 139 | + } | |
| 140 | + queue!(self.out, style::Print(cell.ch))?; | |
| 141 | + at = Some((x + 1, y)); | |
| 142 | + } | |
| 143 | + queue!(self.out, style::ResetColor)?; | |
| 144 | + match cursor { | |
| 145 | + Some((x, y)) => queue!(self.out, cursor::MoveTo(x, y), cursor::Show)?, | |
| 146 | + None => queue!(self.out, cursor::Hide)?, | |
| 147 | + } | |
| 148 | + self.out.flush()?; | |
| 149 | + self.last = screen.clone(); | |
| 150 | + Ok(()) | |
| 151 | + } | |
| 152 | +} | |
| 153 | + | |
| 154 | +impl Drop for Term { | |
| 155 | + fn drop(&mut self) { | |
| 156 | + self.close(); | |
| 157 | + } | |
| 158 | +} | |
| 159 | + | |
| 160 | +fn convert(color: Color) -> style::Color { | |
| 161 | + match color { | |
| 162 | + Color::Default => style::Color::Reset, | |
| 163 | + // The terminal downgrades the 256-colour palette itself, which is the | |
| 164 | + // only place that knows how many colours it really has. | |
| 165 | + Color::Indexed(i) => style::Color::AnsiValue(i), | |
| 166 | + Color::Rgb(r, g, b) => style::Color::Rgb { r, g, b }, | |
| 167 | + } | |
| 168 | +} | |
| 169 | + | |
| 170 | +fn write_style(out: &mut Stdout, style: Style) -> io::Result<()> { | |
| 171 | + use style::Attribute; | |
| 172 | + queue!(out, style::SetAttribute(Attribute::Reset))?; | |
| 173 | + for (bit, on) in [ | |
| 174 | + (attr::BOLD, Attribute::Bold), | |
| 175 | + (attr::DIM, Attribute::Dim), | |
| 176 | + (attr::UNDERLINE, Attribute::Underlined), | |
| 177 | + (attr::REVERSE, Attribute::Reverse), | |
| 178 | + (attr::BLINK, Attribute::SlowBlink), | |
| 179 | + (attr::ITALIC, Attribute::Italic), | |
| 180 | + ] { | |
| 181 | + if style.has(bit) { | |
| 182 | + queue!(out, style::SetAttribute(on))?; | |
| 183 | + } | |
| 184 | + } | |
| 185 | + queue!( | |
| 186 | + out, | |
| 187 | + style::SetForegroundColor(convert(style.fg)), | |
| 188 | + style::SetBackgroundColor(convert(style.bg)) | |
| 189 | + ) | |
| 190 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,190 @@ | |||
| 1 | +//! The terminal itself: raw mode, the alternate screen, and the diff that | ||
| 2 | +//! turns a grid of cells into the fewest escape sequences that will do. | ||
| 3 | +//! | ||
| 4 | +//! Everything above this file paints into a [`Screen`] and never writes a byte, | ||
| 5 | +//! which is what makes the widget layer testable. This is the one place that | ||
| 6 | +//! knows a TTY exists, and it is behind the `terminal` feature so a build for a | ||
| 7 | +//! machine with no terminal crate at all still has the tree and the layout. | ||
| 8 | + | ||
| 9 | +use std::io::{self, Stdout, Write}; | ||
| 10 | + | ||
| 11 | +use crossterm::event::{ | ||
| 12 | + DisableMouseCapture, EnableMouseCapture, Event, KeyEventKind, MouseButton, MouseEventKind, | ||
| 13 | +}; | ||
| 14 | +use crossterm::terminal::{ | ||
| 15 | + disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen, | ||
| 16 | +}; | ||
| 17 | +use crossterm::{cursor, execute, queue, style}; | ||
| 18 | + | ||
| 19 | +use crate::keys; | ||
| 20 | +use crate::screen::{attr, Color, Screen, Style}; | ||
| 21 | + | ||
| 22 | +/// One thing that happened, in the vocabulary [`crate::ui::Ui`] takes. | ||
| 23 | +#[derive(Clone, Debug, PartialEq, Eq)] | ||
| 24 | +pub enum Input { | ||
| 25 | + Key(String), | ||
| 26 | + Click(u16, u16), | ||
| 27 | + Wheel(u16, u16, i32), | ||
| 28 | + Resize(u16, u16), | ||
| 29 | +} | ||
| 30 | + | ||
| 31 | +pub struct Term { | ||
| 32 | + out: Stdout, | ||
| 33 | + /// What is on the screen now, so a frame only sends what changed. | ||
| 34 | + last: Screen, | ||
| 35 | + mouse: bool, | ||
| 36 | +} | ||
| 37 | + | ||
| 38 | +impl Term { | ||
| 39 | + /// Take the terminal: raw mode, the alternate screen, mouse reporting, and | ||
| 40 | + /// no cursor until a focused entry asks for one. | ||
| 41 | + pub fn open(mouse: bool) -> io::Result<Self> { | ||
| 42 | + let (w, h) = crossterm::terminal::size().unwrap_or((80, 24)); | ||
| 43 | + let mut out = io::stdout(); | ||
| 44 | + enable_raw_mode()?; | ||
| 45 | + execute!(out, EnterAlternateScreen, cursor::Hide)?; | ||
| 46 | + if mouse { | ||
| 47 | + execute!(out, EnableMouseCapture)?; | ||
| 48 | + } | ||
| 49 | + Ok(Self { | ||
| 50 | + out, | ||
| 51 | + last: Screen::new(w, h), | ||
| 52 | + mouse, | ||
| 53 | + }) | ||
| 54 | + } | ||
| 55 | + | ||
| 56 | + pub fn size(&self) -> (u16, u16) { | ||
| 57 | + crossterm::terminal::size().unwrap_or((80, 24)) | ||
| 58 | + } | ||
| 59 | + | ||
| 60 | + /// Give the terminal back. Called from `tui_close`, and again from a panic | ||
| 61 | + /// hook — leaving a shell in raw mode with no cursor is the one failure a | ||
| 62 | + /// TUI must not have. | ||
| 63 | + pub fn close(&mut self) { | ||
| 64 | + if self.mouse { | ||
| 65 | + let _ = execute!(self.out, DisableMouseCapture); | ||
| 66 | + } | ||
| 67 | + let _ = execute!(self.out, cursor::Show, LeaveAlternateScreen); | ||
| 68 | + let _ = disable_raw_mode(); | ||
| 69 | + } | ||
| 70 | + | ||
| 71 | + /// Wait up to `timeout_ms` for input and answer everything that had | ||
| 72 | + /// arrived. A zero timeout is a poll. | ||
| 73 | + pub fn poll(&mut self, timeout_ms: u64) -> Vec<Input> { | ||
| 74 | + let mut out = Vec::new(); | ||
| 75 | + let deadline = std::time::Duration::from_millis(timeout_ms); | ||
| 76 | + if !crossterm::event::poll(deadline).unwrap_or(false) { | ||
| 77 | + return out; | ||
| 78 | + } | ||
| 79 | + // Drain what is queued rather than one event a call: a held arrow key | ||
| 80 | + // or a paste arrives as a burst, and handling one per frame would make | ||
| 81 | + // the UI lag behind the keyboard. | ||
| 82 | + loop { | ||
| 83 | + match crossterm::event::read() { | ||
| 84 | + Ok(Event::Key(key)) => { | ||
| 85 | + // Windows reports both press and release; a release would | ||
| 86 | + // type every character twice. | ||
| 87 | + if key.kind != KeyEventKind::Release { | ||
| 88 | + if let Some(name) = keys::name(key) { | ||
| 89 | + out.push(Input::Key(name)); | ||
| 90 | + } | ||
| 91 | + } | ||
| 92 | + } | ||
| 93 | + Ok(Event::Resize(w, h)) => out.push(Input::Resize(w, h)), | ||
| 94 | + Ok(Event::Mouse(mouse)) => match mouse.kind { | ||
| 95 | + MouseEventKind::Down(MouseButton::Left) => { | ||
| 96 | + out.push(Input::Click(mouse.column, mouse.row)) | ||
| 97 | + } | ||
| 98 | + MouseEventKind::ScrollDown => { | ||
| 99 | + out.push(Input::Wheel(mouse.column, mouse.row, 1)) | ||
| 100 | + } | ||
| 101 | + MouseEventKind::ScrollUp => out.push(Input::Wheel(mouse.column, mouse.row, -1)), | ||
| 102 | + _ => {} | ||
| 103 | + }, | ||
| 104 | + Ok(_) => {} | ||
| 105 | + Err(_) => break, | ||
| 106 | + } | ||
| 107 | + if !crossterm::event::poll(std::time::Duration::ZERO).unwrap_or(false) { | ||
| 108 | + break; | ||
| 109 | + } | ||
| 110 | + } | ||
| 111 | + out | ||
| 112 | + } | ||
| 113 | + | ||
| 114 | + /// Send whatever differs between `screen` and what is already up there. | ||
| 115 | + pub fn flush(&mut self, screen: &Screen, cursor: Option<(u16, u16)>) -> io::Result<()> { | ||
| 116 | + if (self.last.width(), self.last.height()) != (screen.width(), screen.height()) { | ||
| 117 | + self.last = Screen::new(screen.width(), screen.height()); | ||
| 118 | + queue!( | ||
| 119 | + self.out, | ||
| 120 | + crossterm::terminal::Clear(crossterm::terminal::ClearType::All) | ||
| 121 | + )?; | ||
| 122 | + } | ||
| 123 | + let mut style = None; | ||
| 124 | + let mut at: Option<(u16, u16)> = None; | ||
| 125 | + let width = screen.width(); | ||
| 126 | + for (i, cell) in screen.cells().iter().enumerate() { | ||
| 127 | + let (x, y) = ((i as u16) % width, (i as u16) / width); | ||
| 128 | + if self.last.cell(x, y) == Some(cell) { | ||
| 129 | + continue; | ||
| 130 | + } | ||
| 131 | + // Only move when the run breaks: a full-width change is one seek | ||
| 132 | + // and a line of text, not a seek a cell. | ||
| 133 | + if at != Some((x, y)) { | ||
| 134 | + queue!(self.out, cursor::MoveTo(x, y))?; | ||
| 135 | + } | ||
| 136 | + if style != Some(cell.style) { | ||
| 137 | + write_style(&mut self.out, cell.style)?; | ||
| 138 | + style = Some(cell.style); | ||
| 139 | + } | ||
| 140 | + queue!(self.out, style::Print(cell.ch))?; | ||
| 141 | + at = Some((x + 1, y)); | ||
| 142 | + } | ||
| 143 | + queue!(self.out, style::ResetColor)?; | ||
| 144 | + match cursor { | ||
| 145 | + Some((x, y)) => queue!(self.out, cursor::MoveTo(x, y), cursor::Show)?, | ||
| 146 | + None => queue!(self.out, cursor::Hide)?, | ||
| 147 | + } | ||
| 148 | + self.out.flush()?; | ||
| 149 | + self.last = screen.clone(); | ||
| 150 | + Ok(()) | ||
| 151 | + } | ||
| 152 | +} | ||
| 153 | + | ||
| 154 | +impl Drop for Term { | ||
| 155 | + fn drop(&mut self) { | ||
| 156 | + self.close(); | ||
| 157 | + } | ||
| 158 | +} | ||
| 159 | + | ||
| 160 | +fn convert(color: Color) -> style::Color { | ||
| 161 | + match color { | ||
| 162 | + Color::Default => style::Color::Reset, | ||
| 163 | + // The terminal downgrades the 256-colour palette itself, which is the | ||
| 164 | + // only place that knows how many colours it really has. | ||
| 165 | + Color::Indexed(i) => style::Color::AnsiValue(i), | ||
| 166 | + Color::Rgb(r, g, b) => style::Color::Rgb { r, g, b }, | ||
| 167 | + } | ||
| 168 | +} | ||
| 169 | + | ||
| 170 | +fn write_style(out: &mut Stdout, style: Style) -> io::Result<()> { | ||
| 171 | + use style::Attribute; | ||
| 172 | + queue!(out, style::SetAttribute(Attribute::Reset))?; | ||
| 173 | + for (bit, on) in [ | ||
| 174 | + (attr::BOLD, Attribute::Bold), | ||
| 175 | + (attr::DIM, Attribute::Dim), | ||
| 176 | + (attr::UNDERLINE, Attribute::Underlined), | ||
| 177 | + (attr::REVERSE, Attribute::Reverse), | ||
| 178 | + (attr::BLINK, Attribute::SlowBlink), | ||
| 179 | + (attr::ITALIC, Attribute::Italic), | ||
| 180 | + ] { | ||
| 181 | + if style.has(bit) { | ||
| 182 | + queue!(out, style::SetAttribute(on))?; | ||
| 183 | + } | ||
| 184 | + } | ||
| 185 | + queue!( | ||
| 186 | + out, | ||
| 187 | + style::SetForegroundColor(convert(style.fg)), | ||
| 188 | + style::SetBackgroundColor(convert(style.bg)) | ||
| 189 | + ) | ||
| 190 | +} | ||
added
crates/jolt-tui/src/tests.rs +371 -0 | new file mode 100644 | ||
| @@ -0,0 +1,371 @@ | ||
| 1 | +//! The backend end to end, with no terminal. | |
| 2 | +//! | |
| 3 | +//! Every one of these mounts real nodes through the same calls the ABI makes, | |
| 4 | +//! paints a real frame, and asserts on the lines that came out — which is the | |
| 5 | +//! whole reason painting goes through a grid. Nothing here needs a TTY, a | |
| 6 | +//! display or raw mode, so it all runs in CI. | |
| 7 | + | |
| 8 | +use crate::screen::attr; | |
| 9 | +use crate::tree::Value; | |
| 10 | +use crate::ui::Ui; | |
| 11 | + | |
| 12 | +fn ui() -> Ui { | |
| 13 | + Ui::new(24, 8) | |
| 14 | +} | |
| 15 | + | |
| 16 | +/// Mount `tag` under `parent` with the string props given. | |
| 17 | +fn node(ui: &mut Ui, parent: u32, tag: &str, props: &[(&str, &str)]) -> u32 { | |
| 18 | + let id = ui.tree.new_node(tag); | |
| 19 | + for (key, value) in props { | |
| 20 | + ui.tree.set(id, key, Value::Str((*value).to_owned())); | |
| 21 | + } | |
| 22 | + ui.tree.append(parent, id); | |
| 23 | + id | |
| 24 | +} | |
| 25 | + | |
| 26 | +fn events(ui: &mut Ui) -> Vec<(u32, String, String, f64)> { | |
| 27 | + let mut out = Vec::new(); | |
| 28 | + while ui.tree.poll() { | |
| 29 | + let event = ui.tree.current().unwrap(); | |
| 30 | + out.push(( | |
| 31 | + event.node, | |
| 32 | + event.name.to_owned(), | |
| 33 | + event.text.clone(), | |
| 34 | + event.num, | |
| 35 | + )); | |
| 36 | + } | |
| 37 | + out | |
| 38 | +} | |
| 39 | + | |
| 40 | +#[test] | |
| 41 | +fn a_column_paints_its_children_down_the_page() { | |
| 42 | + let mut ui = ui(); | |
| 43 | + let root = ui.tree.root(); | |
| 44 | + node(&mut ui, root, "label", &[("label", "first")]); | |
| 45 | + node(&mut ui, root, "label", &[("label", "second")]); | |
| 46 | + ui.frame(); | |
| 47 | + assert_eq!(ui.screen.line(0), "first"); | |
| 48 | + assert_eq!(ui.screen.line(1), "second"); | |
| 49 | +} | |
| 50 | + | |
| 51 | +#[test] | |
| 52 | +fn a_row_paints_its_children_across_with_its_spacing_between_them() { | |
| 53 | + let mut ui = ui(); | |
| 54 | + let root = ui.tree.root(); | |
| 55 | + let row = node(&mut ui, root, "hbox", &[("orientation", "horizontal")]); | |
| 56 | + ui.tree.set(row, "spacing", Value::Num(2.0)); | |
| 57 | + node(&mut ui, row, "label", &[("label", "aa")]); | |
| 58 | + node(&mut ui, row, "label", &[("label", "bb")]); | |
| 59 | + ui.frame(); | |
| 60 | + assert_eq!(ui.screen.line(0), "aa bb"); | |
| 61 | +} | |
| 62 | + | |
| 63 | +#[test] | |
| 64 | +fn a_label_wraps_to_the_width_it_was_given() { | |
| 65 | + let mut ui = Ui::new(10, 4); | |
| 66 | + let root = ui.tree.root(); | |
| 67 | + node(&mut ui, root, "label", &[("label", "one two three four")]); | |
| 68 | + ui.frame(); | |
| 69 | + assert_eq!(ui.screen.line(0), "one two"); | |
| 70 | + assert_eq!(ui.screen.line(1), "three four"); | |
| 71 | +} | |
| 72 | + | |
| 73 | +#[test] | |
| 74 | +fn a_frame_draws_a_border_with_its_label_in_the_top_edge() { | |
| 75 | + let mut ui = Ui::new(12, 4); | |
| 76 | + let root = ui.tree.root(); | |
| 77 | + let frame = node(&mut ui, root, "frame", &[("label", "Task")]); | |
| 78 | + node(&mut ui, frame, "label", &[("label", "hi")]); | |
| 79 | + ui.frame(); | |
| 80 | + assert_eq!(ui.screen.line(0), "┌ Task ────┐"); | |
| 81 | + assert_eq!(ui.screen.line(1), "│hi │"); | |
| 82 | + assert_eq!(ui.screen.line(2), "└──────────┘"); | |
| 83 | +} | |
| 84 | + | |
| 85 | +#[test] | |
| 86 | +fn the_first_focusable_widget_takes_focus_and_tab_walks_the_ring() { | |
| 87 | + let mut ui = ui(); | |
| 88 | + let root = ui.tree.root(); | |
| 89 | + let first = node(&mut ui, root, "button", &[("label", "one")]); | |
| 90 | + let second = node(&mut ui, root, "button", &[("label", "two")]); | |
| 91 | + ui.frame(); | |
| 92 | + assert_eq!(ui.focus(), first); | |
| 93 | + ui.key("tab"); | |
| 94 | + assert_eq!(ui.focus(), second); | |
| 95 | + ui.key("tab"); | |
| 96 | + assert_eq!(ui.focus(), first, "the ring wraps"); | |
| 97 | + ui.key("shift+tab"); | |
| 98 | + assert_eq!(ui.focus(), second); | |
| 99 | +} | |
| 100 | + | |
| 101 | +#[test] | |
| 102 | +fn autofocus_beats_paint_order() { | |
| 103 | + let mut ui = ui(); | |
| 104 | + let root = ui.tree.root(); | |
| 105 | + node(&mut ui, root, "button", &[("label", "one")]); | |
| 106 | + let wanted = node(&mut ui, root, "entry", &[]); | |
| 107 | + ui.tree.set(wanted, "autofocus", Value::Bool(true)); | |
| 108 | + ui.frame(); | |
| 109 | + assert_eq!(ui.focus(), wanted); | |
| 110 | +} | |
| 111 | + | |
| 112 | +#[test] | |
| 113 | +fn a_focused_button_is_drawn_in_reverse_and_activates_on_enter() { | |
| 114 | + let mut ui = ui(); | |
| 115 | + let root = ui.tree.root(); | |
| 116 | + let button = node(&mut ui, root, "button", &[("label", "go")]); | |
| 117 | + ui.frame(); | |
| 118 | + assert_eq!(ui.screen.line(0), "[ go ]"); | |
| 119 | + assert!(ui.screen.cell(0, 0).unwrap().style.has(attr::REVERSE)); | |
| 120 | + ui.key("enter"); | |
| 121 | + assert_eq!( | |
| 122 | + events(&mut ui), | |
| 123 | + vec![(button, "click".into(), String::new(), 0.0)] | |
| 124 | + ); | |
| 125 | +} | |
| 126 | + | |
| 127 | +#[test] | |
| 128 | +fn a_checkbutton_writes_its_new_state_back_as_well_as_reporting_it() { | |
| 129 | + let mut ui = ui(); | |
| 130 | + let root = ui.tree.root(); | |
| 131 | + let check = node(&mut ui, root, "checkbutton", &[("label", "live")]); | |
| 132 | + ui.frame(); | |
| 133 | + assert_eq!(ui.screen.line(0), "[ ] live"); | |
| 134 | + ui.key("space"); | |
| 135 | + assert_eq!( | |
| 136 | + events(&mut ui), | |
| 137 | + vec![(check, "toggled".into(), String::new(), 1.0)] | |
| 138 | + ); | |
| 139 | + // A caller that ignores the event still has a working control. | |
| 140 | + assert_eq!(ui.tree.props(check).bool("active", false), true); | |
| 141 | + ui.frame(); | |
| 142 | + assert_eq!(ui.screen.line(0), "[x] live"); | |
| 143 | +} | |
| 144 | + | |
| 145 | +#[test] | |
| 146 | +fn typing_in_an_entry_edits_its_text_and_reports_every_change() { | |
| 147 | + let mut ui = ui(); | |
| 148 | + let root = ui.tree.root(); | |
| 149 | + let entry = node(&mut ui, root, "entry", &[("placeholder", "name")]); | |
| 150 | + ui.frame(); | |
| 151 | + assert_eq!( | |
| 152 | + ui.screen.line(0), | |
| 153 | + "name", | |
| 154 | + "the placeholder shows until it is typed in" | |
| 155 | + ); | |
| 156 | + for key in ["h", "i", "space", "there"] { | |
| 157 | + for name in [key] { | |
| 158 | + if name.chars().count() > 1 && name != "space" { | |
| 159 | + for ch in name.chars() { | |
| 160 | + ui.key(&ch.to_string()); | |
| 161 | + } | |
| 162 | + } else { | |
| 163 | + ui.key(name); | |
| 164 | + } | |
| 165 | + } | |
| 166 | + } | |
| 167 | + assert_eq!(ui.tree.props(entry).str("text"), "hi there"); | |
| 168 | + let changes = events(&mut ui); | |
| 169 | + assert_eq!(changes.len(), 8); | |
| 170 | + assert_eq!(changes.last().unwrap().2, "hi there"); | |
| 171 | + ui.frame(); | |
| 172 | + assert_eq!(ui.screen.line(0), "hi there"); | |
| 173 | +} | |
| 174 | + | |
| 175 | +#[test] | |
| 176 | +fn readline_keys_edit_where_the_caret_is() { | |
| 177 | + let mut ui = ui(); | |
| 178 | + let root = ui.tree.root(); | |
| 179 | + let entry = node(&mut ui, root, "entry", &[("text", "one two three")]); | |
| 180 | + ui.frame(); | |
| 181 | + ui.key("ctrl+w"); | |
| 182 | + assert_eq!(ui.tree.props(entry).str("text"), "one two "); | |
| 183 | + ui.key("ctrl+a"); | |
| 184 | + ui.key("delete"); | |
| 185 | + assert_eq!(ui.tree.props(entry).str("text"), "ne two "); | |
| 186 | + ui.key("ctrl+e"); | |
| 187 | + ui.key("backspace"); | |
| 188 | + assert_eq!(ui.tree.props(entry).str("text"), "ne two"); | |
| 189 | + ui.key("ctrl+u"); | |
| 190 | + assert_eq!(ui.tree.props(entry).str("text"), ""); | |
| 191 | +} | |
| 192 | + | |
| 193 | +#[test] | |
| 194 | +fn enter_in_an_entry_activates_rather_than_typing() { | |
| 195 | + let mut ui = ui(); | |
| 196 | + let root = ui.tree.root(); | |
| 197 | + let entry = node(&mut ui, root, "entry", &[("text", "search")]); | |
| 198 | + ui.frame(); | |
| 199 | + ui.key("enter"); | |
| 200 | + assert_eq!( | |
| 201 | + events(&mut ui), | |
| 202 | + vec![(entry, "activate".into(), "search".into(), 0.0)] | |
| 203 | + ); | |
| 204 | + assert_eq!(ui.tree.props(entry).str("text"), "search"); | |
| 205 | +} | |
| 206 | + | |
| 207 | +#[test] | |
| 208 | +fn a_listbox_moves_its_cursor_with_the_arrows_and_with_j_and_k() { | |
| 209 | + let mut ui = ui(); | |
| 210 | + let root = ui.tree.root(); | |
| 211 | + let list = node(&mut ui, root, "listbox", &[]); | |
| 212 | + for name in ["alpha", "beta", "gamma"] { | |
| 213 | + node(&mut ui, list, "label", &[("label", name)]); | |
| 214 | + } | |
| 215 | + ui.frame(); | |
| 216 | + assert_eq!(ui.screen.line(0), "› alpha"); | |
| 217 | + ui.key("j"); | |
| 218 | + assert_eq!( | |
| 219 | + events(&mut ui), | |
| 220 | + vec![(list, "select".into(), "beta".into(), 1.0)] | |
| 221 | + ); | |
| 222 | + ui.key("G"); | |
| 223 | + assert_eq!(ui.tree.props(list).num("selected", -1.0), 2.0); | |
| 224 | + ui.key("enter"); | |
| 225 | + assert_eq!( | |
| 226 | + events(&mut ui), | |
| 227 | + vec![ | |
| 228 | + (list, "select".into(), "gamma".into(), 2.0), | |
| 229 | + (list, "activate".into(), "gamma".into(), 2.0) | |
| 230 | + ] | |
| 231 | + ); | |
| 232 | + ui.frame(); | |
| 233 | + assert_eq!(ui.screen.line(2), "› gamma"); | |
| 234 | +} | |
| 235 | + | |
| 236 | +#[test] | |
| 237 | +fn a_key_nothing_wanted_reaches_the_caller_as_an_event() { | |
| 238 | + let mut ui = ui(); | |
| 239 | + let root = ui.tree.root(); | |
| 240 | + let button = node(&mut ui, root, "button", &[("label", "go")]); | |
| 241 | + ui.frame(); | |
| 242 | + assert!(!ui.key("f5")); | |
| 243 | + assert_eq!( | |
| 244 | + events(&mut ui), | |
| 245 | + vec![(button, "key".into(), "f5".into(), 0.0)] | |
| 246 | + ); | |
| 247 | +} | |
| 248 | + | |
| 249 | +#[test] | |
| 250 | +fn ctrl_c_asks_the_loop_to_stop() { | |
| 251 | + let mut ui = ui(); | |
| 252 | + assert!(!ui.should_close()); | |
| 253 | + ui.key("ctrl+c"); | |
| 254 | + assert!(ui.should_close()); | |
| 255 | +} | |
| 256 | + | |
| 257 | +#[test] | |
| 258 | +fn an_insensitive_subtree_is_dimmed_and_out_of_the_focus_ring() { | |
| 259 | + let mut ui = ui(); | |
| 260 | + let root = ui.tree.root(); | |
| 261 | + let column = node(&mut ui, root, "vbox", &[]); | |
| 262 | + ui.tree.set(column, "sensitive", Value::Bool(false)); | |
| 263 | + node(&mut ui, column, "button", &[("label", "go")]); | |
| 264 | + let live = node(&mut ui, root, "button", &[("label", "live")]); | |
| 265 | + ui.frame(); | |
| 266 | + assert!(ui.screen.cell(0, 0).unwrap().style.has(attr::DIM)); | |
| 267 | + assert_eq!(ui.focus(), live); | |
| 268 | +} | |
| 269 | + | |
| 270 | +#[test] | |
| 271 | +fn a_click_focuses_and_activates_what_is_under_it() { | |
| 272 | + let mut ui = ui(); | |
| 273 | + let root = ui.tree.root(); | |
| 274 | + node(&mut ui, root, "button", &[("label", "one")]); | |
| 275 | + let second = node(&mut ui, root, "button", &[("label", "two")]); | |
| 276 | + ui.frame(); | |
| 277 | + assert!(ui.click(2, 1)); | |
| 278 | + assert_eq!(ui.focus(), second); | |
| 279 | + assert_eq!( | |
| 280 | + events(&mut ui), | |
| 281 | + vec![(second, "click".into(), String::new(), 0.0)] | |
| 282 | + ); | |
| 283 | + assert!(!ui.click(20, 7), "a click on nothing is not an event"); | |
| 284 | +} | |
| 285 | + | |
| 286 | +#[test] | |
| 287 | +fn a_scroll_shows_a_window_of_its_content_and_will_not_go_past_the_end() { | |
| 288 | + let mut ui = Ui::new(10, 3); | |
| 289 | + let root = ui.tree.root(); | |
| 290 | + let scroll = node(&mut ui, root, "scroll", &[]); | |
| 291 | + for i in 0..6 { | |
| 292 | + node(&mut ui, scroll, "label", &[("label", &format!("row {i}"))]); | |
| 293 | + } | |
| 294 | + ui.frame(); | |
| 295 | + assert_eq!(ui.screen.lines(), vec!["row 0", "row 1", "row 2"]); | |
| 296 | + ui.wheel(1, 1, 2); | |
| 297 | + ui.frame(); | |
| 298 | + assert_eq!(ui.screen.lines(), vec!["row 2", "row 3", "row 4"]); | |
| 299 | + ui.wheel(1, 1, 40); | |
| 300 | + ui.frame(); | |
| 301 | + assert_eq!(ui.screen.lines(), vec!["row 3", "row 4", "row 5"]); | |
| 302 | + // The clamped viewport is written back, so the next scroll up starts from | |
| 303 | + // where the reader actually is. | |
| 304 | + assert_eq!(ui.tree.props(scroll).num("offset", -1.0), 3.0); | |
| 305 | +} | |
| 306 | + | |
| 307 | +#[test] | |
| 308 | +fn an_overlay_floats_in_the_middle_over_whatever_was_under_it() { | |
| 309 | + let mut ui = Ui::new(14, 5); | |
| 310 | + let root = ui.tree.root(); | |
| 311 | + node(&mut ui, root, "label", &[("label", "beneath")]); | |
| 312 | + let overlay = node(&mut ui, root, "overlay", &[("label", "Sure?")]); | |
| 313 | + node(&mut ui, overlay, "label", &[("label", "yes")]); | |
| 314 | + ui.frame(); | |
| 315 | + assert_eq!(ui.screen.line(1), " ┌ Sure? ┐"); | |
| 316 | + assert_eq!(ui.screen.line(2), " │yes │"); | |
| 317 | + ui.key("esc"); | |
| 318 | + assert_eq!( | |
| 319 | + events(&mut ui), | |
| 320 | + vec![(overlay, "close".into(), String::new(), 0.0)] | |
| 321 | + ); | |
| 322 | +} | |
| 323 | + | |
| 324 | +#[test] | |
| 325 | +fn a_progress_bar_fills_the_share_of_its_width_it_was_given() { | |
| 326 | + let mut ui = Ui::new(10, 2); | |
| 327 | + let root = ui.tree.root(); | |
| 328 | + let bar = node(&mut ui, root, "progress", &[]); | |
| 329 | + ui.tree.set(bar, "value", Value::Num(0.5)); | |
| 330 | + ui.frame(); | |
| 331 | + assert_eq!(ui.screen.line(0), "█████░░░░░"); | |
| 332 | +} | |
| 333 | + | |
| 334 | +#[test] | |
| 335 | +fn colours_and_attributes_are_inherited_by_a_subtree() { | |
| 336 | + let mut ui = ui(); | |
| 337 | + let root = ui.tree.root(); | |
| 338 | + let column = node(&mut ui, root, "vbox", &[("color", "red")]); | |
| 339 | + ui.tree.set(column, "bold", Value::Bool(true)); | |
| 340 | + node(&mut ui, column, "label", &[("label", "hi")]); | |
| 341 | + ui.frame(); | |
| 342 | + let cell = ui.screen.cell(0, 0).unwrap(); | |
| 343 | + assert_eq!(cell.style.fg, crate::screen::Color::Indexed(1)); | |
| 344 | + assert!(cell.style.has(attr::BOLD)); | |
| 345 | +} | |
| 346 | + | |
| 347 | +#[test] | |
| 348 | +fn an_unknown_tag_still_shows_its_children() { | |
| 349 | + let mut ui = ui(); | |
| 350 | + let root = ui.tree.root(); | |
| 351 | + let odd = node(&mut ui, root, "sparkline", &[]); | |
| 352 | + node(&mut ui, odd, "label", &[("label", "inside")]); | |
| 353 | + ui.frame(); | |
| 354 | + assert_eq!(ui.screen.line(0), "inside"); | |
| 355 | +} | |
| 356 | + | |
| 357 | +#[test] | |
| 358 | +fn focus_survives_a_repaint_and_lands_somewhere_when_its_widget_is_unmounted() { | |
| 359 | + let mut ui = ui(); | |
| 360 | + let root = ui.tree.root(); | |
| 361 | + let first = node(&mut ui, root, "button", &[("label", "one")]); | |
| 362 | + let second = node(&mut ui, root, "button", &[("label", "two")]); | |
| 363 | + ui.frame(); | |
| 364 | + ui.key("tab"); | |
| 365 | + assert_eq!(ui.focus(), second); | |
| 366 | + ui.frame(); | |
| 367 | + assert_eq!(ui.focus(), second, "a repaint does not move the focus"); | |
| 368 | + ui.tree.remove(root, second); | |
| 369 | + ui.frame(); | |
| 370 | + assert_eq!(ui.focus(), first); | |
| 371 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,371 @@ | |||
| 1 | +//! The backend end to end, with no terminal. | ||
| 2 | +//! | ||
| 3 | +//! Every one of these mounts real nodes through the same calls the ABI makes, | ||
| 4 | +//! paints a real frame, and asserts on the lines that came out — which is the | ||
| 5 | +//! whole reason painting goes through a grid. Nothing here needs a TTY, a | ||
| 6 | +//! display or raw mode, so it all runs in CI. | ||
| 7 | + | ||
| 8 | +use crate::screen::attr; | ||
| 9 | +use crate::tree::Value; | ||
| 10 | +use crate::ui::Ui; | ||
| 11 | + | ||
| 12 | +fn ui() -> Ui { | ||
| 13 | + Ui::new(24, 8) | ||
| 14 | +} | ||
| 15 | + | ||
| 16 | +/// Mount `tag` under `parent` with the string props given. | ||
| 17 | +fn node(ui: &mut Ui, parent: u32, tag: &str, props: &[(&str, &str)]) -> u32 { | ||
| 18 | + let id = ui.tree.new_node(tag); | ||
| 19 | + for (key, value) in props { | ||
| 20 | + ui.tree.set(id, key, Value::Str((*value).to_owned())); | ||
| 21 | + } | ||
| 22 | + ui.tree.append(parent, id); | ||
| 23 | + id | ||
| 24 | +} | ||
| 25 | + | ||
| 26 | +fn events(ui: &mut Ui) -> Vec<(u32, String, String, f64)> { | ||
| 27 | + let mut out = Vec::new(); | ||
| 28 | + while ui.tree.poll() { | ||
| 29 | + let event = ui.tree.current().unwrap(); | ||
| 30 | + out.push(( | ||
| 31 | + event.node, | ||
| 32 | + event.name.to_owned(), | ||
| 33 | + event.text.clone(), | ||
| 34 | + event.num, | ||
| 35 | + )); | ||
| 36 | + } | ||
| 37 | + out | ||
| 38 | +} | ||
| 39 | + | ||
| 40 | +#[test] | ||
| 41 | +fn a_column_paints_its_children_down_the_page() { | ||
| 42 | + let mut ui = ui(); | ||
| 43 | + let root = ui.tree.root(); | ||
| 44 | + node(&mut ui, root, "label", &[("label", "first")]); | ||
| 45 | + node(&mut ui, root, "label", &[("label", "second")]); | ||
| 46 | + ui.frame(); | ||
| 47 | + assert_eq!(ui.screen.line(0), "first"); | ||
| 48 | + assert_eq!(ui.screen.line(1), "second"); | ||
| 49 | +} | ||
| 50 | + | ||
| 51 | +#[test] | ||
| 52 | +fn a_row_paints_its_children_across_with_its_spacing_between_them() { | ||
| 53 | + let mut ui = ui(); | ||
| 54 | + let root = ui.tree.root(); | ||
| 55 | + let row = node(&mut ui, root, "hbox", &[("orientation", "horizontal")]); | ||
| 56 | + ui.tree.set(row, "spacing", Value::Num(2.0)); | ||
| 57 | + node(&mut ui, row, "label", &[("label", "aa")]); | ||
| 58 | + node(&mut ui, row, "label", &[("label", "bb")]); | ||
| 59 | + ui.frame(); | ||
| 60 | + assert_eq!(ui.screen.line(0), "aa bb"); | ||
| 61 | +} | ||
| 62 | + | ||
| 63 | +#[test] | ||
| 64 | +fn a_label_wraps_to_the_width_it_was_given() { | ||
| 65 | + let mut ui = Ui::new(10, 4); | ||
| 66 | + let root = ui.tree.root(); | ||
| 67 | + node(&mut ui, root, "label", &[("label", "one two three four")]); | ||
| 68 | + ui.frame(); | ||
| 69 | + assert_eq!(ui.screen.line(0), "one two"); | ||
| 70 | + assert_eq!(ui.screen.line(1), "three four"); | ||
| 71 | +} | ||
| 72 | + | ||
| 73 | +#[test] | ||
| 74 | +fn a_frame_draws_a_border_with_its_label_in_the_top_edge() { | ||
| 75 | + let mut ui = Ui::new(12, 4); | ||
| 76 | + let root = ui.tree.root(); | ||
| 77 | + let frame = node(&mut ui, root, "frame", &[("label", "Task")]); | ||
| 78 | + node(&mut ui, frame, "label", &[("label", "hi")]); | ||
| 79 | + ui.frame(); | ||
| 80 | + assert_eq!(ui.screen.line(0), "┌ Task ────┐"); | ||
| 81 | + assert_eq!(ui.screen.line(1), "│hi │"); | ||
| 82 | + assert_eq!(ui.screen.line(2), "└──────────┘"); | ||
| 83 | +} | ||
| 84 | + | ||
| 85 | +#[test] | ||
| 86 | +fn the_first_focusable_widget_takes_focus_and_tab_walks_the_ring() { | ||
| 87 | + let mut ui = ui(); | ||
| 88 | + let root = ui.tree.root(); | ||
| 89 | + let first = node(&mut ui, root, "button", &[("label", "one")]); | ||
| 90 | + let second = node(&mut ui, root, "button", &[("label", "two")]); | ||
| 91 | + ui.frame(); | ||
| 92 | + assert_eq!(ui.focus(), first); | ||
| 93 | + ui.key("tab"); | ||
| 94 | + assert_eq!(ui.focus(), second); | ||
| 95 | + ui.key("tab"); | ||
| 96 | + assert_eq!(ui.focus(), first, "the ring wraps"); | ||
| 97 | + ui.key("shift+tab"); | ||
| 98 | + assert_eq!(ui.focus(), second); | ||
| 99 | +} | ||
| 100 | + | ||
| 101 | +#[test] | ||
| 102 | +fn autofocus_beats_paint_order() { | ||
| 103 | + let mut ui = ui(); | ||
| 104 | + let root = ui.tree.root(); | ||
| 105 | + node(&mut ui, root, "button", &[("label", "one")]); | ||
| 106 | + let wanted = node(&mut ui, root, "entry", &[]); | ||
| 107 | + ui.tree.set(wanted, "autofocus", Value::Bool(true)); | ||
| 108 | + ui.frame(); | ||
| 109 | + assert_eq!(ui.focus(), wanted); | ||
| 110 | +} | ||
| 111 | + | ||
| 112 | +#[test] | ||
| 113 | +fn a_focused_button_is_drawn_in_reverse_and_activates_on_enter() { | ||
| 114 | + let mut ui = ui(); | ||
| 115 | + let root = ui.tree.root(); | ||
| 116 | + let button = node(&mut ui, root, "button", &[("label", "go")]); | ||
| 117 | + ui.frame(); | ||
| 118 | + assert_eq!(ui.screen.line(0), "[ go ]"); | ||
| 119 | + assert!(ui.screen.cell(0, 0).unwrap().style.has(attr::REVERSE)); | ||
| 120 | + ui.key("enter"); | ||
| 121 | + assert_eq!( | ||
| 122 | + events(&mut ui), | ||
| 123 | + vec![(button, "click".into(), String::new(), 0.0)] | ||
| 124 | + ); | ||
| 125 | +} | ||
| 126 | + | ||
| 127 | +#[test] | ||
| 128 | +fn a_checkbutton_writes_its_new_state_back_as_well_as_reporting_it() { | ||
| 129 | + let mut ui = ui(); | ||
| 130 | + let root = ui.tree.root(); | ||
| 131 | + let check = node(&mut ui, root, "checkbutton", &[("label", "live")]); | ||
| 132 | + ui.frame(); | ||
| 133 | + assert_eq!(ui.screen.line(0), "[ ] live"); | ||
| 134 | + ui.key("space"); | ||
| 135 | + assert_eq!( | ||
| 136 | + events(&mut ui), | ||
| 137 | + vec![(check, "toggled".into(), String::new(), 1.0)] | ||
| 138 | + ); | ||
| 139 | + // A caller that ignores the event still has a working control. | ||
| 140 | + assert_eq!(ui.tree.props(check).bool("active", false), true); | ||
| 141 | + ui.frame(); | ||
| 142 | + assert_eq!(ui.screen.line(0), "[x] live"); | ||
| 143 | +} | ||
| 144 | + | ||
| 145 | +#[test] | ||
| 146 | +fn typing_in_an_entry_edits_its_text_and_reports_every_change() { | ||
| 147 | + let mut ui = ui(); | ||
| 148 | + let root = ui.tree.root(); | ||
| 149 | + let entry = node(&mut ui, root, "entry", &[("placeholder", "name")]); | ||
| 150 | + ui.frame(); | ||
| 151 | + assert_eq!( | ||
| 152 | + ui.screen.line(0), | ||
| 153 | + "name", | ||
| 154 | + "the placeholder shows until it is typed in" | ||
| 155 | + ); | ||
| 156 | + for key in ["h", "i", "space", "there"] { | ||
| 157 | + for name in [key] { | ||
| 158 | + if name.chars().count() > 1 && name != "space" { | ||
| 159 | + for ch in name.chars() { | ||
| 160 | + ui.key(&ch.to_string()); | ||
| 161 | + } | ||
| 162 | + } else { | ||
| 163 | + ui.key(name); | ||
| 164 | + } | ||
| 165 | + } | ||
| 166 | + } | ||
| 167 | + assert_eq!(ui.tree.props(entry).str("text"), "hi there"); | ||
| 168 | + let changes = events(&mut ui); | ||
| 169 | + assert_eq!(changes.len(), 8); | ||
| 170 | + assert_eq!(changes.last().unwrap().2, "hi there"); | ||
| 171 | + ui.frame(); | ||
| 172 | + assert_eq!(ui.screen.line(0), "hi there"); | ||
| 173 | +} | ||
| 174 | + | ||
| 175 | +#[test] | ||
| 176 | +fn readline_keys_edit_where_the_caret_is() { | ||
| 177 | + let mut ui = ui(); | ||
| 178 | + let root = ui.tree.root(); | ||
| 179 | + let entry = node(&mut ui, root, "entry", &[("text", "one two three")]); | ||
| 180 | + ui.frame(); | ||
| 181 | + ui.key("ctrl+w"); | ||
| 182 | + assert_eq!(ui.tree.props(entry).str("text"), "one two "); | ||
| 183 | + ui.key("ctrl+a"); | ||
| 184 | + ui.key("delete"); | ||
| 185 | + assert_eq!(ui.tree.props(entry).str("text"), "ne two "); | ||
| 186 | + ui.key("ctrl+e"); | ||
| 187 | + ui.key("backspace"); | ||
| 188 | + assert_eq!(ui.tree.props(entry).str("text"), "ne two"); | ||
| 189 | + ui.key("ctrl+u"); | ||
| 190 | + assert_eq!(ui.tree.props(entry).str("text"), ""); | ||
| 191 | +} | ||
| 192 | + | ||
| 193 | +#[test] | ||
| 194 | +fn enter_in_an_entry_activates_rather_than_typing() { | ||
| 195 | + let mut ui = ui(); | ||
| 196 | + let root = ui.tree.root(); | ||
| 197 | + let entry = node(&mut ui, root, "entry", &[("text", "search")]); | ||
| 198 | + ui.frame(); | ||
| 199 | + ui.key("enter"); | ||
| 200 | + assert_eq!( | ||
| 201 | + events(&mut ui), | ||
| 202 | + vec![(entry, "activate".into(), "search".into(), 0.0)] | ||
| 203 | + ); | ||
| 204 | + assert_eq!(ui.tree.props(entry).str("text"), "search"); | ||
| 205 | +} | ||
| 206 | + | ||
| 207 | +#[test] | ||
| 208 | +fn a_listbox_moves_its_cursor_with_the_arrows_and_with_j_and_k() { | ||
| 209 | + let mut ui = ui(); | ||
| 210 | + let root = ui.tree.root(); | ||
| 211 | + let list = node(&mut ui, root, "listbox", &[]); | ||
| 212 | + for name in ["alpha", "beta", "gamma"] { | ||
| 213 | + node(&mut ui, list, "label", &[("label", name)]); | ||
| 214 | + } | ||
| 215 | + ui.frame(); | ||
| 216 | + assert_eq!(ui.screen.line(0), "› alpha"); | ||
| 217 | + ui.key("j"); | ||
| 218 | + assert_eq!( | ||
| 219 | + events(&mut ui), | ||
| 220 | + vec![(list, "select".into(), "beta".into(), 1.0)] | ||
| 221 | + ); | ||
| 222 | + ui.key("G"); | ||
| 223 | + assert_eq!(ui.tree.props(list).num("selected", -1.0), 2.0); | ||
| 224 | + ui.key("enter"); | ||
| 225 | + assert_eq!( | ||
| 226 | + events(&mut ui), | ||
| 227 | + vec![ | ||
| 228 | + (list, "select".into(), "gamma".into(), 2.0), | ||
| 229 | + (list, "activate".into(), "gamma".into(), 2.0) | ||
| 230 | + ] | ||
| 231 | + ); | ||
| 232 | + ui.frame(); | ||
| 233 | + assert_eq!(ui.screen.line(2), "› gamma"); | ||
| 234 | +} | ||
| 235 | + | ||
| 236 | +#[test] | ||
| 237 | +fn a_key_nothing_wanted_reaches_the_caller_as_an_event() { | ||
| 238 | + let mut ui = ui(); | ||
| 239 | + let root = ui.tree.root(); | ||
| 240 | + let button = node(&mut ui, root, "button", &[("label", "go")]); | ||
| 241 | + ui.frame(); | ||
| 242 | + assert!(!ui.key("f5")); | ||
| 243 | + assert_eq!( | ||
| 244 | + events(&mut ui), | ||
| 245 | + vec![(button, "key".into(), "f5".into(), 0.0)] | ||
| 246 | + ); | ||
| 247 | +} | ||
| 248 | + | ||
| 249 | +#[test] | ||
| 250 | +fn ctrl_c_asks_the_loop_to_stop() { | ||
| 251 | + let mut ui = ui(); | ||
| 252 | + assert!(!ui.should_close()); | ||
| 253 | + ui.key("ctrl+c"); | ||
| 254 | + assert!(ui.should_close()); | ||
| 255 | +} | ||
| 256 | + | ||
| 257 | +#[test] | ||
| 258 | +fn an_insensitive_subtree_is_dimmed_and_out_of_the_focus_ring() { | ||
| 259 | + let mut ui = ui(); | ||
| 260 | + let root = ui.tree.root(); | ||
| 261 | + let column = node(&mut ui, root, "vbox", &[]); | ||
| 262 | + ui.tree.set(column, "sensitive", Value::Bool(false)); | ||
| 263 | + node(&mut ui, column, "button", &[("label", "go")]); | ||
| 264 | + let live = node(&mut ui, root, "button", &[("label", "live")]); | ||
| 265 | + ui.frame(); | ||
| 266 | + assert!(ui.screen.cell(0, 0).unwrap().style.has(attr::DIM)); | ||
| 267 | + assert_eq!(ui.focus(), live); | ||
| 268 | +} | ||
| 269 | + | ||
| 270 | +#[test] | ||
| 271 | +fn a_click_focuses_and_activates_what_is_under_it() { | ||
| 272 | + let mut ui = ui(); | ||
| 273 | + let root = ui.tree.root(); | ||
| 274 | + node(&mut ui, root, "button", &[("label", "one")]); | ||
| 275 | + let second = node(&mut ui, root, "button", &[("label", "two")]); | ||
| 276 | + ui.frame(); | ||
| 277 | + assert!(ui.click(2, 1)); | ||
| 278 | + assert_eq!(ui.focus(), second); | ||
| 279 | + assert_eq!( | ||
| 280 | + events(&mut ui), | ||
| 281 | + vec![(second, "click".into(), String::new(), 0.0)] | ||
| 282 | + ); | ||
| 283 | + assert!(!ui.click(20, 7), "a click on nothing is not an event"); | ||
| 284 | +} | ||
| 285 | + | ||
| 286 | +#[test] | ||
| 287 | +fn a_scroll_shows_a_window_of_its_content_and_will_not_go_past_the_end() { | ||
| 288 | + let mut ui = Ui::new(10, 3); | ||
| 289 | + let root = ui.tree.root(); | ||
| 290 | + let scroll = node(&mut ui, root, "scroll", &[]); | ||
| 291 | + for i in 0..6 { | ||
| 292 | + node(&mut ui, scroll, "label", &[("label", &format!("row {i}"))]); | ||
| 293 | + } | ||
| 294 | + ui.frame(); | ||
| 295 | + assert_eq!(ui.screen.lines(), vec!["row 0", "row 1", "row 2"]); | ||
| 296 | + ui.wheel(1, 1, 2); | ||
| 297 | + ui.frame(); | ||
| 298 | + assert_eq!(ui.screen.lines(), vec!["row 2", "row 3", "row 4"]); | ||
| 299 | + ui.wheel(1, 1, 40); | ||
| 300 | + ui.frame(); | ||
| 301 | + assert_eq!(ui.screen.lines(), vec!["row 3", "row 4", "row 5"]); | ||
| 302 | + // The clamped viewport is written back, so the next scroll up starts from | ||
| 303 | + // where the reader actually is. | ||
| 304 | + assert_eq!(ui.tree.props(scroll).num("offset", -1.0), 3.0); | ||
| 305 | +} | ||
| 306 | + | ||
| 307 | +#[test] | ||
| 308 | +fn an_overlay_floats_in_the_middle_over_whatever_was_under_it() { | ||
| 309 | + let mut ui = Ui::new(14, 5); | ||
| 310 | + let root = ui.tree.root(); | ||
| 311 | + node(&mut ui, root, "label", &[("label", "beneath")]); | ||
| 312 | + let overlay = node(&mut ui, root, "overlay", &[("label", "Sure?")]); | ||
| 313 | + node(&mut ui, overlay, "label", &[("label", "yes")]); | ||
| 314 | + ui.frame(); | ||
| 315 | + assert_eq!(ui.screen.line(1), " ┌ Sure? ┐"); | ||
| 316 | + assert_eq!(ui.screen.line(2), " │yes │"); | ||
| 317 | + ui.key("esc"); | ||
| 318 | + assert_eq!( | ||
| 319 | + events(&mut ui), | ||
| 320 | + vec![(overlay, "close".into(), String::new(), 0.0)] | ||
| 321 | + ); | ||
| 322 | +} | ||
| 323 | + | ||
| 324 | +#[test] | ||
| 325 | +fn a_progress_bar_fills_the_share_of_its_width_it_was_given() { | ||
| 326 | + let mut ui = Ui::new(10, 2); | ||
| 327 | + let root = ui.tree.root(); | ||
| 328 | + let bar = node(&mut ui, root, "progress", &[]); | ||
| 329 | + ui.tree.set(bar, "value", Value::Num(0.5)); | ||
| 330 | + ui.frame(); | ||
| 331 | + assert_eq!(ui.screen.line(0), "█████░░░░░"); | ||
| 332 | +} | ||
| 333 | + | ||
| 334 | +#[test] | ||
| 335 | +fn colours_and_attributes_are_inherited_by_a_subtree() { | ||
| 336 | + let mut ui = ui(); | ||
| 337 | + let root = ui.tree.root(); | ||
| 338 | + let column = node(&mut ui, root, "vbox", &[("color", "red")]); | ||
| 339 | + ui.tree.set(column, "bold", Value::Bool(true)); | ||
| 340 | + node(&mut ui, column, "label", &[("label", "hi")]); | ||
| 341 | + ui.frame(); | ||
| 342 | + let cell = ui.screen.cell(0, 0).unwrap(); | ||
| 343 | + assert_eq!(cell.style.fg, crate::screen::Color::Indexed(1)); | ||
| 344 | + assert!(cell.style.has(attr::BOLD)); | ||
| 345 | +} | ||
| 346 | + | ||
| 347 | +#[test] | ||
| 348 | +fn an_unknown_tag_still_shows_its_children() { | ||
| 349 | + let mut ui = ui(); | ||
| 350 | + let root = ui.tree.root(); | ||
| 351 | + let odd = node(&mut ui, root, "sparkline", &[]); | ||
| 352 | + node(&mut ui, odd, "label", &[("label", "inside")]); | ||
| 353 | + ui.frame(); | ||
| 354 | + assert_eq!(ui.screen.line(0), "inside"); | ||
| 355 | +} | ||
| 356 | + | ||
| 357 | +#[test] | ||
| 358 | +fn focus_survives_a_repaint_and_lands_somewhere_when_its_widget_is_unmounted() { | ||
| 359 | + let mut ui = ui(); | ||
| 360 | + let root = ui.tree.root(); | ||
| 361 | + let first = node(&mut ui, root, "button", &[("label", "one")]); | ||
| 362 | + let second = node(&mut ui, root, "button", &[("label", "two")]); | ||
| 363 | + ui.frame(); | ||
| 364 | + ui.key("tab"); | ||
| 365 | + assert_eq!(ui.focus(), second); | ||
| 366 | + ui.frame(); | ||
| 367 | + assert_eq!(ui.focus(), second, "a repaint does not move the focus"); | ||
| 368 | + ui.tree.remove(root, second); | ||
| 369 | + ui.frame(); | ||
| 370 | + assert_eq!(ui.focus(), first); | ||
| 371 | +} | ||
added
crates/jolt-tui/src/tree.rs +657 -0 | new file mode 100644 | ||
| @@ -0,0 +1,657 @@ | ||
| 1 | +//! A retained node tree, painted into a character grid. | |
| 2 | +//! | |
| 3 | +//! The same arena glimmer's reconciler expects everywhere else: nodes are | |
| 4 | +//! integer handles, `create` / `apply-props!` / `append-child!` mutate them, | |
| 5 | +//! and nothing is drawn until the frame call walks the whole thing at once. | |
| 6 | +//! Interactions come back as a queue the caller drains, because a jolt closure | |
| 7 | +//! cannot be a callback down here — identity crosses the boundary instead. | |
| 8 | +//! | |
| 9 | +//! This module knows nothing about terminals. It is the data; [`crate::layout`] | |
| 10 | +//! measures it and [`crate::paint`] draws it. | |
| 11 | + | |
| 12 | +use std::collections::{HashMap, VecDeque}; | |
| 13 | + | |
| 14 | +/// A prop value: the three types the ABI can carry, which is all glimmer needs. | |
| 15 | +/// Keywords and colours arrive as strings, numbers as doubles, flags as ints. | |
| 16 | +#[derive(Clone, Debug, PartialEq)] | |
| 17 | +pub enum Value { | |
| 18 | + Str(String), | |
| 19 | + Num(f64), | |
| 20 | + Bool(bool), | |
| 21 | +} | |
| 22 | + | |
| 23 | +/// What a node renders as. | |
| 24 | +/// | |
| 25 | +/// An unknown tag is kept rather than refused — it paints as a vertical box, so | |
| 26 | +/// a component written against a tag this backend has not grown yet still shows | |
| 27 | +/// its children instead of nothing. | |
| 28 | +#[derive(Clone, Debug, PartialEq, Eq)] | |
| 29 | +pub enum Tag { | |
| 30 | + Window, | |
| 31 | + Box, | |
| 32 | + Frame, | |
| 33 | + Scroll, | |
| 34 | + Overlay, | |
| 35 | + Label, | |
| 36 | + Title, | |
| 37 | + DimLabel, | |
| 38 | + Button, | |
| 39 | + CheckButton, | |
| 40 | + Entry, | |
| 41 | + Separator, | |
| 42 | + Spacer, | |
| 43 | + Listbox, | |
| 44 | + Progress, | |
| 45 | + Spinner, | |
| 46 | + Unknown(String), | |
| 47 | +} | |
| 48 | + | |
| 49 | +impl Default for Tag { | |
| 50 | + fn default() -> Self { | |
| 51 | + Self::Unknown(String::new()) | |
| 52 | + } | |
| 53 | +} | |
| 54 | + | |
| 55 | +impl Tag { | |
| 56 | + fn parse(name: &str) -> Self { | |
| 57 | + match name { | |
| 58 | + "window" => Self::Window, | |
| 59 | + "box" | "hbox" | "vbox" => Self::Box, | |
| 60 | + "frame" => Self::Frame, | |
| 61 | + "scroll" => Self::Scroll, | |
| 62 | + "overlay" => Self::Overlay, | |
| 63 | + "label" => Self::Label, | |
| 64 | + "title" | "title-2" => Self::Title, | |
| 65 | + "dim-label" => Self::DimLabel, | |
| 66 | + "button" => Self::Button, | |
| 67 | + "checkbutton" | "checkbox" => Self::CheckButton, | |
| 68 | + "entry" => Self::Entry, | |
| 69 | + "separator" => Self::Separator, | |
| 70 | + "spacer" | "gap" => Self::Spacer, | |
| 71 | + "listbox" => Self::Listbox, | |
| 72 | + "progress" => Self::Progress, | |
| 73 | + "spinner" => Self::Spinner, | |
| 74 | + other => Self::Unknown(other.to_owned()), | |
| 75 | + } | |
| 76 | + } | |
| 77 | + | |
| 78 | + /// The canonical name: `hbox` and `vbox` are one node, so both answer | |
| 79 | + /// `box` and carry their orientation in a prop. | |
| 80 | + pub fn name(&self) -> &str { | |
| 81 | + match self { | |
| 82 | + Self::Window => "window", | |
| 83 | + Self::Box => "box", | |
| 84 | + Self::Frame => "frame", | |
| 85 | + Self::Scroll => "scroll", | |
| 86 | + Self::Overlay => "overlay", | |
| 87 | + Self::Label => "label", | |
| 88 | + Self::Title => "title", | |
| 89 | + Self::DimLabel => "dim-label", | |
| 90 | + Self::Button => "button", | |
| 91 | + Self::CheckButton => "checkbutton", | |
| 92 | + Self::Entry => "entry", | |
| 93 | + Self::Separator => "separator", | |
| 94 | + Self::Spacer => "spacer", | |
| 95 | + Self::Listbox => "listbox", | |
| 96 | + Self::Progress => "progress", | |
| 97 | + Self::Spinner => "spinner", | |
| 98 | + Self::Unknown(name) => name, | |
| 99 | + } | |
| 100 | + } | |
| 101 | + | |
| 102 | + /// Whether the focus ring stops here. A container never takes focus of its | |
| 103 | + /// own; a control that does nothing with a key does not either. | |
| 104 | + pub fn focusable(&self) -> bool { | |
| 105 | + matches!( | |
| 106 | + self, | |
| 107 | + Self::Button | Self::CheckButton | Self::Entry | Self::Listbox | |
| 108 | + ) | |
| 109 | + } | |
| 110 | +} | |
| 111 | + | |
| 112 | +/// One interaction, waiting to be drained by the caller. Names are glimmer's | |
| 113 | +/// handler props with the `on-` dropped. | |
| 114 | +#[derive(Clone, Debug, PartialEq)] | |
| 115 | +pub struct Event { | |
| 116 | + pub node: u32, | |
| 117 | + pub name: &'static str, | |
| 118 | + pub text: String, | |
| 119 | + pub num: f64, | |
| 120 | +} | |
| 121 | + | |
| 122 | +#[derive(Clone, Debug, Default)] | |
| 123 | +struct Node { | |
| 124 | + tag: Tag, | |
| 125 | + props: HashMap<String, Value>, | |
| 126 | + children: Vec<u32>, | |
| 127 | + /// 0 when unparented. The root's parent is 0 as well, which is what stops | |
| 128 | + /// the ancestor walk in [`Tree::would_cycle`]. | |
| 129 | + parent: u32, | |
| 130 | +} | |
| 131 | + | |
| 132 | +/// A node's props, copied out for the duration of one measure or paint. | |
| 133 | +/// | |
| 134 | +/// Reading them through this rather than the map means a missing prop and a | |
| 135 | +/// prop of the wrong type answer the same thing: the default. Nothing a caller | |
| 136 | +/// can write should be able to make a widget vanish. | |
| 137 | +#[derive(Clone, Debug, Default)] | |
| 138 | +pub struct Props(pub HashMap<String, Value>); | |
| 139 | + | |
| 140 | +impl Props { | |
| 141 | + pub fn str(&self, key: &str) -> &str { | |
| 142 | + match self.0.get(key) { | |
| 143 | + Some(Value::Str(s)) => s, | |
| 144 | + _ => "", | |
| 145 | + } | |
| 146 | + } | |
| 147 | + | |
| 148 | + pub fn num(&self, key: &str, fallback: f64) -> f64 { | |
| 149 | + match self.0.get(key) { | |
| 150 | + Some(Value::Num(n)) => *n, | |
| 151 | + Some(Value::Bool(b)) => { | |
| 152 | + if *b { | |
| 153 | + 1.0 | |
| 154 | + } else { | |
| 155 | + 0.0 | |
| 156 | + } | |
| 157 | + } | |
| 158 | + _ => fallback, | |
| 159 | + } | |
| 160 | + } | |
| 161 | + | |
| 162 | + /// A count of cells. Negative and absurd values are clamped rather than | |
| 163 | + /// cast, since `as u16` on a negative double is a silent 0 or 65535. | |
| 164 | + pub fn cells(&self, key: &str, fallback: u16) -> u16 { | |
| 165 | + match self.0.get(key) { | |
| 166 | + Some(Value::Num(n)) if n.is_finite() => n.clamp(0.0, u16::MAX as f64) as u16, | |
| 167 | + _ => fallback, | |
| 168 | + } | |
| 169 | + } | |
| 170 | + | |
| 171 | + pub fn bool(&self, key: &str, fallback: bool) -> bool { | |
| 172 | + match self.0.get(key) { | |
| 173 | + Some(Value::Bool(b)) => *b, | |
| 174 | + Some(Value::Num(n)) => *n != 0.0, | |
| 175 | + Some(Value::Str(s)) => s == "true", | |
| 176 | + _ => fallback, | |
| 177 | + } | |
| 178 | + } | |
| 179 | + | |
| 180 | + pub fn has(&self, key: &str) -> bool { | |
| 181 | + self.0.contains_key(key) | |
| 182 | + } | |
| 183 | + | |
| 184 | + /// The text a widget shows. `:label` and `:text` are the same prop to every | |
| 185 | + /// glimmer backend; whichever the caller wrote is the one that shows. | |
| 186 | + pub fn label(&self) -> &str { | |
| 187 | + if self.has("label") { | |
| 188 | + self.str("label") | |
| 189 | + } else { | |
| 190 | + self.str("text") | |
| 191 | + } | |
| 192 | + } | |
| 193 | +} | |
| 194 | + | |
| 195 | +/// One prop value as EDN. Whole numbers print without a trailing `.0`: every | |
| 196 | +/// number crossed the boundary as a double, and `{:spacing 8}` reads better | |
| 197 | +/// than `{:spacing 8.0}`. | |
| 198 | +fn write_value(value: &Value, out: &mut String) { | |
| 199 | + match value { | |
| 200 | + Value::Bool(b) => out.push_str(if *b { "true" } else { "false" }), | |
| 201 | + Value::Num(n) => { | |
| 202 | + if n.is_finite() && n.fract() == 0.0 && n.abs() < 1e15 { | |
| 203 | + out.push_str(&format!("{}", *n as i64)); | |
| 204 | + } else if n.is_finite() { | |
| 205 | + out.push_str(&format!("{n}")); | |
| 206 | + } else { | |
| 207 | + // EDN has no infinity or NaN literal; say nil rather than emit | |
| 208 | + // something no reader will take. | |
| 209 | + out.push_str("nil"); | |
| 210 | + } | |
| 211 | + } | |
| 212 | + Value::Str(text) => { | |
| 213 | + out.push('"'); | |
| 214 | + for c in text.chars() { | |
| 215 | + match c { | |
| 216 | + '"' => out.push_str("\\\""), | |
| 217 | + '\\' => out.push_str("\\\\"), | |
| 218 | + '\n' => out.push_str("\\n"), | |
| 219 | + '\r' => out.push_str("\\r"), | |
| 220 | + '\t' => out.push_str("\\t"), | |
| 221 | + _ => out.push(c), | |
| 222 | + } | |
| 223 | + } | |
| 224 | + out.push('"'); | |
| 225 | + } | |
| 226 | + } | |
| 227 | +} | |
| 228 | + | |
| 229 | +pub struct Tree { | |
| 230 | + /// Index 0 is never handed out: 0 is "no node" throughout the ABI. | |
| 231 | + nodes: Vec<Option<Node>>, | |
| 232 | + free: Vec<u32>, | |
| 233 | + root: u32, | |
| 234 | + pending: VecDeque<Event>, | |
| 235 | + current: Option<Event>, | |
| 236 | +} | |
| 237 | + | |
| 238 | +impl Default for Tree { | |
| 239 | + fn default() -> Self { | |
| 240 | + Self::new() | |
| 241 | + } | |
| 242 | +} | |
| 243 | + | |
| 244 | +impl Tree { | |
| 245 | + pub fn new() -> Self { | |
| 246 | + let mut tree = Self { | |
| 247 | + nodes: vec![None], | |
| 248 | + free: Vec::new(), | |
| 249 | + root: 0, | |
| 250 | + pending: VecDeque::new(), | |
| 251 | + current: None, | |
| 252 | + }; | |
| 253 | + tree.root = tree.new_node("window"); | |
| 254 | + tree | |
| 255 | + } | |
| 256 | + | |
| 257 | + pub fn root(&self) -> u32 { | |
| 258 | + self.root | |
| 259 | + } | |
| 260 | + | |
| 261 | + fn slot(&self, id: u32) -> Option<&Node> { | |
| 262 | + self.nodes.get(id as usize).and_then(|n| n.as_ref()) | |
| 263 | + } | |
| 264 | + | |
| 265 | + fn slot_mut(&mut self, id: u32) -> Option<&mut Node> { | |
| 266 | + self.nodes.get_mut(id as usize).and_then(|n| n.as_mut()) | |
| 267 | + } | |
| 268 | + | |
| 269 | + pub fn exists(&self, id: u32) -> bool { | |
| 270 | + self.slot(id).is_some() | |
| 271 | + } | |
| 272 | + | |
| 273 | + pub fn new_node(&mut self, tag: &str) -> u32 { | |
| 274 | + let node = Node { | |
| 275 | + tag: Tag::parse(tag), | |
| 276 | + ..Node::default() | |
| 277 | + }; | |
| 278 | + match self.free.pop() { | |
| 279 | + Some(id) => { | |
| 280 | + self.nodes[id as usize] = Some(node); | |
| 281 | + id | |
| 282 | + } | |
| 283 | + None => { | |
| 284 | + self.nodes.push(Some(node)); | |
| 285 | + (self.nodes.len() - 1) as u32 | |
| 286 | + } | |
| 287 | + } | |
| 288 | + } | |
| 289 | + | |
| 290 | + /// Free `id` and everything under it. The root is refused: the window node | |
| 291 | + /// is the one thing a caller cannot drop out from under itself. | |
| 292 | + pub fn free_node(&mut self, id: u32) { | |
| 293 | + if id == self.root || !self.exists(id) { | |
| 294 | + return; | |
| 295 | + } | |
| 296 | + let parent = self.slot(id).map(|n| n.parent).unwrap_or(0); | |
| 297 | + if parent != 0 { | |
| 298 | + if let Some(node) = self.slot_mut(parent) { | |
| 299 | + node.children.retain(|c| *c != id); | |
| 300 | + } | |
| 301 | + } | |
| 302 | + self.free_subtree(id); | |
| 303 | + } | |
| 304 | + | |
| 305 | + fn free_subtree(&mut self, id: u32) { | |
| 306 | + let children = self | |
| 307 | + .slot(id) | |
| 308 | + .map(|n| n.children.clone()) | |
| 309 | + .unwrap_or_default(); | |
| 310 | + for child in children { | |
| 311 | + self.free_subtree(child); | |
| 312 | + } | |
| 313 | + if self.nodes[id as usize].take().is_some() { | |
| 314 | + self.free.push(id); | |
| 315 | + } | |
| 316 | + // An event queued against a node that has since gone would be routed to | |
| 317 | + // a handler the reconciler has already dropped. Drop it here instead. | |
| 318 | + self.pending.retain(|e| e.node != id); | |
| 319 | + } | |
| 320 | + | |
| 321 | + /// Whether making `child` a child of `parent` would make a loop — `child` | |
| 322 | + /// being `parent` or one of its ancestors. | |
| 323 | + fn would_cycle(&self, parent: u32, child: u32) -> bool { | |
| 324 | + let mut at = parent; | |
| 325 | + while at != 0 { | |
| 326 | + if at == child { | |
| 327 | + return true; | |
| 328 | + } | |
| 329 | + at = match self.slot(at) { | |
| 330 | + Some(node) => node.parent, | |
| 331 | + None => return false, | |
| 332 | + }; | |
| 333 | + } | |
| 334 | + false | |
| 335 | + } | |
| 336 | + | |
| 337 | + fn unparent(&mut self, child: u32) { | |
| 338 | + let parent = self.slot(child).map(|n| n.parent).unwrap_or(0); | |
| 339 | + if parent != 0 { | |
| 340 | + if let Some(node) = self.slot_mut(parent) { | |
| 341 | + node.children.retain(|c| *c != child); | |
| 342 | + } | |
| 343 | + } | |
| 344 | + if let Some(node) = self.slot_mut(child) { | |
| 345 | + node.parent = 0; | |
| 346 | + } | |
| 347 | + } | |
| 348 | + | |
| 349 | + pub fn append(&mut self, parent: u32, child: u32) -> bool { | |
| 350 | + if !self.exists(parent) || !self.exists(child) || self.would_cycle(parent, child) { | |
| 351 | + return false; | |
| 352 | + } | |
| 353 | + self.unparent(child); | |
| 354 | + self.slot_mut(parent).unwrap().children.push(child); | |
| 355 | + self.slot_mut(child).unwrap().parent = parent; | |
| 356 | + true | |
| 357 | + } | |
| 358 | + | |
| 359 | + /// Unparent *and* free `child`, which is what the reconciler means by | |
| 360 | + /// remove: a node it has taken out of the tree is a node it has dropped. | |
| 361 | + pub fn remove(&mut self, parent: u32, child: u32) { | |
| 362 | + if self.slot(child).map(|n| n.parent) == Some(parent) { | |
| 363 | + self.free_node(child); | |
| 364 | + } | |
| 365 | + } | |
| 366 | + | |
| 367 | + /// Move `child` after `sibling`; `sibling` 0 means the first position. | |
| 368 | + pub fn insert_after(&mut self, parent: u32, child: u32, sibling: u32) -> bool { | |
| 369 | + if !self.exists(parent) || !self.exists(child) || self.would_cycle(parent, child) { | |
| 370 | + return false; | |
| 371 | + } | |
| 372 | + if sibling != 0 && self.slot(sibling).map(|n| n.parent) != Some(parent) { | |
| 373 | + return false; | |
| 374 | + } | |
| 375 | + self.unparent(child); | |
| 376 | + let at = match sibling { | |
| 377 | + 0 => 0, | |
| 378 | + _ => { | |
| 379 | + let children = &self.slot(parent).unwrap().children; | |
| 380 | + children | |
| 381 | + .iter() | |
| 382 | + .position(|c| *c == sibling) | |
| 383 | + .map_or(0, |i| i + 1) | |
| 384 | + } | |
| 385 | + }; | |
| 386 | + self.slot_mut(parent).unwrap().children.insert(at, child); | |
| 387 | + self.slot_mut(child).unwrap().parent = parent; | |
| 388 | + true | |
| 389 | + } | |
| 390 | + | |
| 391 | + /// Put `new` where `old` was, and free `old`. | |
| 392 | + pub fn replace(&mut self, parent: u32, old: u32, new: u32) -> bool { | |
| 393 | + if self.slot(old).map(|n| n.parent) != Some(parent) || !self.exists(new) { | |
| 394 | + return false; | |
| 395 | + } | |
| 396 | + if self.would_cycle(parent, new) { | |
| 397 | + return false; | |
| 398 | + } | |
| 399 | + self.unparent(new); | |
| 400 | + let at = self | |
| 401 | + .slot(parent) | |
| 402 | + .and_then(|n| n.children.iter().position(|c| *c == old)); | |
| 403 | + let Some(at) = at else { return false }; | |
| 404 | + self.slot_mut(parent).unwrap().children[at] = new; | |
| 405 | + self.slot_mut(new).unwrap().parent = parent; | |
| 406 | + if let Some(node) = self.slot_mut(old) { | |
| 407 | + node.parent = 0; | |
| 408 | + } | |
| 409 | + self.free_subtree(old); | |
| 410 | + true | |
| 411 | + } | |
| 412 | + | |
| 413 | + // ── reading it back ───────────────────────────────────────────────────── | |
| 414 | + | |
| 415 | + pub fn tag(&self, id: u32) -> Tag { | |
| 416 | + self.slot(id).map(|n| n.tag.clone()).unwrap_or_default() | |
| 417 | + } | |
| 418 | + | |
| 419 | + pub fn tag_name(&self, id: u32) -> &str { | |
| 420 | + self.slot(id).map_or("", |n| n.tag.name()) | |
| 421 | + } | |
| 422 | + | |
| 423 | + pub fn children(&self, id: u32) -> Vec<u32> { | |
| 424 | + self.slot(id) | |
| 425 | + .map(|n| n.children.clone()) | |
| 426 | + .unwrap_or_default() | |
| 427 | + } | |
| 428 | + | |
| 429 | + pub fn child_count(&self, id: u32) -> usize { | |
| 430 | + self.slot(id).map_or(0, |n| n.children.len()) | |
| 431 | + } | |
| 432 | + | |
| 433 | + pub fn child_at(&self, id: u32, index: usize) -> u32 { | |
| 434 | + self.slot(id) | |
| 435 | + .and_then(|n| n.children.get(index).copied()) | |
| 436 | + .unwrap_or(0) | |
| 437 | + } | |
| 438 | + | |
| 439 | + pub fn parent(&self, id: u32) -> u32 { | |
| 440 | + self.slot(id).map_or(0, |n| n.parent) | |
| 441 | + } | |
| 442 | + | |
| 443 | + pub fn props(&self, id: u32) -> Props { | |
| 444 | + Props(self.slot(id).map(|n| n.props.clone()).unwrap_or_default()) | |
| 445 | + } | |
| 446 | + | |
| 447 | + pub fn set(&mut self, id: u32, key: &str, value: Value) { | |
| 448 | + if let Some(node) = self.slot_mut(id) { | |
| 449 | + node.props.insert(key.to_owned(), value); | |
| 450 | + } | |
| 451 | + } | |
| 452 | + | |
| 453 | + pub fn clear_props(&mut self, id: u32) { | |
| 454 | + if let Some(node) = self.slot_mut(id) { | |
| 455 | + node.props.clear(); | |
| 456 | + } | |
| 457 | + } | |
| 458 | + | |
| 459 | + pub fn get(&self, id: u32, key: &str) -> Option<&Value> { | |
| 460 | + self.slot(id).and_then(|n| n.props.get(key)) | |
| 461 | + } | |
| 462 | + | |
| 463 | + /// The subtree at `id` as pretty-printed hiccup — what the tree *is*, read | |
| 464 | + /// back from the arena, rather than what a component meant to build. | |
| 465 | + /// | |
| 466 | + /// Props are sorted, so two dumps of the same tree compare as text. | |
| 467 | + pub fn dump(&self, id: u32) -> String { | |
| 468 | + let mut out = String::new(); | |
| 469 | + self.dump_into(id, 0, &mut out); | |
| 470 | + out | |
| 471 | + } | |
| 472 | + | |
| 473 | + fn dump_into(&self, id: u32, depth: usize, out: &mut String) { | |
| 474 | + let Some(node) = self.slot(id) else { | |
| 475 | + out.push_str("nil"); | |
| 476 | + return; | |
| 477 | + }; | |
| 478 | + let indent = " ".repeat(depth); | |
| 479 | + out.push_str("[:"); | |
| 480 | + out.push_str(node.tag.name()); | |
| 481 | + | |
| 482 | + let mut keys: Vec<&String> = node.props.keys().collect(); | |
| 483 | + keys.sort(); | |
| 484 | + out.push_str(" {"); | |
| 485 | + for (i, key) in keys.iter().enumerate() { | |
| 486 | + if i > 0 { | |
| 487 | + out.push(' '); | |
| 488 | + } | |
| 489 | + out.push(':'); | |
| 490 | + out.push_str(key); | |
| 491 | + out.push(' '); | |
| 492 | + write_value(&node.props[*key], out); | |
| 493 | + } | |
| 494 | + out.push('}'); | |
| 495 | + | |
| 496 | + for child in &node.children { | |
| 497 | + out.push('\n'); | |
| 498 | + out.push_str(&indent); | |
| 499 | + out.push_str(" "); | |
| 500 | + self.dump_into(*child, depth + 1, out); | |
| 501 | + } | |
| 502 | + out.push(']'); | |
| 503 | + } | |
| 504 | + | |
| 505 | + // ── events ────────────────────────────────────────────────────────────── | |
| 506 | + | |
| 507 | + pub fn emit(&mut self, node: u32, name: &'static str, text: String, num: f64) { | |
| 508 | + self.pending.push_back(Event { | |
| 509 | + node, | |
| 510 | + name, | |
| 511 | + text, | |
| 512 | + num, | |
| 513 | + }); | |
| 514 | + } | |
| 515 | + | |
| 516 | + /// Dequeue one event into the accessor slot. False when the queue is empty. | |
| 517 | + pub fn poll(&mut self) -> bool { | |
| 518 | + self.current = self.pending.pop_front(); | |
| 519 | + self.current.is_some() | |
| 520 | + } | |
| 521 | + | |
| 522 | + pub fn current(&self) -> Option<&Event> { | |
| 523 | + self.current.as_ref() | |
| 524 | + } | |
| 525 | +} | |
| 526 | + | |
| 527 | +#[cfg(test)] | |
| 528 | +mod tests { | |
| 529 | + use super::*; | |
| 530 | + | |
| 531 | + fn tree_with_button() -> (Tree, u32) { | |
| 532 | + let mut tree = Tree::new(); | |
| 533 | + let button = tree.new_node("button"); | |
| 534 | + tree.set(button, "label", Value::Str("go".into())); | |
| 535 | + let root = tree.root(); | |
| 536 | + tree.append(root, button); | |
| 537 | + (tree, button) | |
| 538 | + } | |
| 539 | + | |
| 540 | + #[test] | |
| 541 | + fn a_dump_is_the_tree_as_hiccup_with_sorted_props() { | |
| 542 | + let (mut tree, button) = tree_with_button(); | |
| 543 | + tree.set(button, "kind", Value::Str("primary".into())); | |
| 544 | + assert_eq!( | |
| 545 | + tree.dump(tree.root()), | |
| 546 | + "[:window {}\n [:button {:kind \"primary\" :label \"go\"}]]" | |
| 547 | + ); | |
| 548 | + } | |
| 549 | + | |
| 550 | + #[test] | |
| 551 | + fn hbox_and_vbox_are_one_node() { | |
| 552 | + let mut tree = Tree::new(); | |
| 553 | + let h = tree.new_node("hbox"); | |
| 554 | + let v = tree.new_node("vbox"); | |
| 555 | + assert_eq!(tree.tag_name(h), "box"); | |
| 556 | + assert_eq!(tree.tag_name(v), "box"); | |
| 557 | + } | |
| 558 | + | |
| 559 | + #[test] | |
| 560 | + fn an_unknown_tag_keeps_its_name() { | |
| 561 | + let mut tree = Tree::new(); | |
| 562 | + let node = tree.new_node("sparkline"); | |
| 563 | + assert_eq!(tree.tag_name(node), "sparkline"); | |
| 564 | + assert_eq!(tree.tag(node), Tag::Unknown("sparkline".into())); | |
| 565 | + } | |
| 566 | + | |
| 567 | + #[test] | |
| 568 | + fn removing_a_node_frees_its_subtree_and_reuses_the_handles() { | |
| 569 | + let mut tree = Tree::new(); | |
| 570 | + let outer = tree.new_node("vbox"); | |
| 571 | + let inner = tree.new_node("label"); | |
| 572 | + tree.append(outer, inner); | |
| 573 | + tree.append(tree.root(), outer); | |
| 574 | + tree.remove(tree.root(), outer); | |
| 575 | + assert!(!tree.exists(outer)); | |
| 576 | + assert!(!tree.exists(inner)); | |
| 577 | + assert_eq!(tree.child_count(tree.root()), 0); | |
| 578 | + // The arena hands the slots back out rather than growing forever. | |
| 579 | + assert!([outer, inner].contains(&tree.new_node("label"))); | |
| 580 | + } | |
| 581 | + | |
| 582 | + #[test] | |
| 583 | + fn a_node_cannot_become_its_own_ancestor() { | |
| 584 | + let mut tree = Tree::new(); | |
| 585 | + let outer = tree.new_node("vbox"); | |
| 586 | + let inner = tree.new_node("vbox"); | |
| 587 | + tree.append(outer, inner); | |
| 588 | + assert!(!tree.append(inner, outer)); | |
| 589 | + assert_eq!(tree.parent(outer), 0); | |
| 590 | + } | |
| 591 | + | |
| 592 | + #[test] | |
| 593 | + fn insert_after_zero_is_the_first_position() { | |
| 594 | + let mut tree = Tree::new(); | |
| 595 | + let (a, b, c) = ( | |
| 596 | + tree.new_node("label"), | |
| 597 | + tree.new_node("label"), | |
| 598 | + tree.new_node("label"), | |
| 599 | + ); | |
| 600 | + let root = tree.root(); | |
| 601 | + tree.append(root, a); | |
| 602 | + tree.append(root, b); | |
| 603 | + tree.insert_after(root, c, 0); | |
| 604 | + assert_eq!(tree.children(root), vec![c, a, b]); | |
| 605 | + tree.insert_after(root, c, a); | |
| 606 | + assert_eq!(tree.children(root), vec![a, c, b]); | |
| 607 | + } | |
| 608 | + | |
| 609 | + #[test] | |
| 610 | + fn replace_keeps_the_position_and_frees_the_old_node() { | |
| 611 | + let mut tree = Tree::new(); | |
| 612 | + let root = tree.root(); | |
| 613 | + let (a, b) = (tree.new_node("label"), tree.new_node("label")); | |
| 614 | + tree.append(root, a); | |
| 615 | + tree.append(root, b); | |
| 616 | + let fresh = tree.new_node("button"); | |
| 617 | + assert!(tree.replace(root, a, fresh)); | |
| 618 | + assert_eq!(tree.children(root), vec![fresh, b]); | |
| 619 | + assert!(!tree.exists(a)); | |
| 620 | + } | |
| 621 | + | |
| 622 | + #[test] | |
| 623 | + fn the_root_cannot_be_freed() { | |
| 624 | + let mut tree = Tree::new(); | |
| 625 | + let root = tree.root(); | |
| 626 | + tree.free_node(root); | |
| 627 | + assert!(tree.exists(root)); | |
| 628 | + } | |
| 629 | + | |
| 630 | + #[test] | |
| 631 | + fn an_event_for_a_freed_node_never_reaches_the_caller() { | |
| 632 | + let (mut tree, button) = tree_with_button(); | |
| 633 | + tree.emit(button, "click", String::new(), 0.0); | |
| 634 | + tree.remove(tree.root(), button); | |
| 635 | + assert!(!tree.poll()); | |
| 636 | + } | |
| 637 | + | |
| 638 | + #[test] | |
| 639 | + fn props_of_the_wrong_type_read_as_the_default() { | |
| 640 | + let mut tree = Tree::new(); | |
| 641 | + let node = tree.new_node("progress"); | |
| 642 | + tree.set(node, "value", Value::Str("lots".into())); | |
| 643 | + let props = tree.props(node); | |
| 644 | + assert_eq!(props.num("value", 0.5), 0.5); | |
| 645 | + assert_eq!(props.cells("width-request", 7), 7); | |
| 646 | + } | |
| 647 | + | |
| 648 | + #[test] | |
| 649 | + fn label_and_text_are_the_same_prop() { | |
| 650 | + let mut tree = Tree::new(); | |
| 651 | + let node = tree.new_node("label"); | |
| 652 | + tree.set(node, "text", Value::Str("hello".into())); | |
| 653 | + assert_eq!(tree.props(node).label(), "hello"); | |
| 654 | + tree.set(node, "label", Value::Str("hi".into())); | |
| 655 | + assert_eq!(tree.props(node).label(), "hi"); | |
| 656 | + } | |
| 657 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,657 @@ | |||
| 1 | +//! A retained node tree, painted into a character grid. | ||
| 2 | +//! | ||
| 3 | +//! The same arena glimmer's reconciler expects everywhere else: nodes are | ||
| 4 | +//! integer handles, `create` / `apply-props!` / `append-child!` mutate them, | ||
| 5 | +//! and nothing is drawn until the frame call walks the whole thing at once. | ||
| 6 | +//! Interactions come back as a queue the caller drains, because a jolt closure | ||
| 7 | +//! cannot be a callback down here — identity crosses the boundary instead. | ||
| 8 | +//! | ||
| 9 | +//! This module knows nothing about terminals. It is the data; [`crate::layout`] | ||
| 10 | +//! measures it and [`crate::paint`] draws it. | ||
| 11 | + | ||
| 12 | +use std::collections::{HashMap, VecDeque}; | ||
| 13 | + | ||
| 14 | +/// A prop value: the three types the ABI can carry, which is all glimmer needs. | ||
| 15 | +/// Keywords and colours arrive as strings, numbers as doubles, flags as ints. | ||
| 16 | +#[derive(Clone, Debug, PartialEq)] | ||
| 17 | +pub enum Value { | ||
| 18 | + Str(String), | ||
| 19 | + Num(f64), | ||
| 20 | + Bool(bool), | ||
| 21 | +} | ||
| 22 | + | ||
| 23 | +/// What a node renders as. | ||
| 24 | +/// | ||
| 25 | +/// An unknown tag is kept rather than refused — it paints as a vertical box, so | ||
| 26 | +/// a component written against a tag this backend has not grown yet still shows | ||
| 27 | +/// its children instead of nothing. | ||
| 28 | +#[derive(Clone, Debug, PartialEq, Eq)] | ||
| 29 | +pub enum Tag { | ||
| 30 | + Window, | ||
| 31 | + Box, | ||
| 32 | + Frame, | ||
| 33 | + Scroll, | ||
| 34 | + Overlay, | ||
| 35 | + Label, | ||
| 36 | + Title, | ||
| 37 | + DimLabel, | ||
| 38 | + Button, | ||
| 39 | + CheckButton, | ||
| 40 | + Entry, | ||
| 41 | + Separator, | ||
| 42 | + Spacer, | ||
| 43 | + Listbox, | ||
| 44 | + Progress, | ||
| 45 | + Spinner, | ||
| 46 | + Unknown(String), | ||
| 47 | +} | ||
| 48 | + | ||
| 49 | +impl Default for Tag { | ||
| 50 | + fn default() -> Self { | ||
| 51 | + Self::Unknown(String::new()) | ||
| 52 | + } | ||
| 53 | +} | ||
| 54 | + | ||
| 55 | +impl Tag { | ||
| 56 | + fn parse(name: &str) -> Self { | ||
| 57 | + match name { | ||
| 58 | + "window" => Self::Window, | ||
| 59 | + "box" | "hbox" | "vbox" => Self::Box, | ||
| 60 | + "frame" => Self::Frame, | ||
| 61 | + "scroll" => Self::Scroll, | ||
| 62 | + "overlay" => Self::Overlay, | ||
| 63 | + "label" => Self::Label, | ||
| 64 | + "title" | "title-2" => Self::Title, | ||
| 65 | + "dim-label" => Self::DimLabel, | ||
| 66 | + "button" => Self::Button, | ||
| 67 | + "checkbutton" | "checkbox" => Self::CheckButton, | ||
| 68 | + "entry" => Self::Entry, | ||
| 69 | + "separator" => Self::Separator, | ||
| 70 | + "spacer" | "gap" => Self::Spacer, | ||
| 71 | + "listbox" => Self::Listbox, | ||
| 72 | + "progress" => Self::Progress, | ||
| 73 | + "spinner" => Self::Spinner, | ||
| 74 | + other => Self::Unknown(other.to_owned()), | ||
| 75 | + } | ||
| 76 | + } | ||
| 77 | + | ||
| 78 | + /// The canonical name: `hbox` and `vbox` are one node, so both answer | ||
| 79 | + /// `box` and carry their orientation in a prop. | ||
| 80 | + pub fn name(&self) -> &str { | ||
| 81 | + match self { | ||
| 82 | + Self::Window => "window", | ||
| 83 | + Self::Box => "box", | ||
| 84 | + Self::Frame => "frame", | ||
| 85 | + Self::Scroll => "scroll", | ||
| 86 | + Self::Overlay => "overlay", | ||
| 87 | + Self::Label => "label", | ||
| 88 | + Self::Title => "title", | ||
| 89 | + Self::DimLabel => "dim-label", | ||
| 90 | + Self::Button => "button", | ||
| 91 | + Self::CheckButton => "checkbutton", | ||
| 92 | + Self::Entry => "entry", | ||
| 93 | + Self::Separator => "separator", | ||
| 94 | + Self::Spacer => "spacer", | ||
| 95 | + Self::Listbox => "listbox", | ||
| 96 | + Self::Progress => "progress", | ||
| 97 | + Self::Spinner => "spinner", | ||
| 98 | + Self::Unknown(name) => name, | ||
| 99 | + } | ||
| 100 | + } | ||
| 101 | + | ||
| 102 | + /// Whether the focus ring stops here. A container never takes focus of its | ||
| 103 | + /// own; a control that does nothing with a key does not either. | ||
| 104 | + pub fn focusable(&self) -> bool { | ||
| 105 | + matches!( | ||
| 106 | + self, | ||
| 107 | + Self::Button | Self::CheckButton | Self::Entry | Self::Listbox | ||
| 108 | + ) | ||
| 109 | + } | ||
| 110 | +} | ||
| 111 | + | ||
| 112 | +/// One interaction, waiting to be drained by the caller. Names are glimmer's | ||
| 113 | +/// handler props with the `on-` dropped. | ||
| 114 | +#[derive(Clone, Debug, PartialEq)] | ||
| 115 | +pub struct Event { | ||
| 116 | + pub node: u32, | ||
| 117 | + pub name: &'static str, | ||
| 118 | + pub text: String, | ||
| 119 | + pub num: f64, | ||
| 120 | +} | ||
| 121 | + | ||
| 122 | +#[derive(Clone, Debug, Default)] | ||
| 123 | +struct Node { | ||
| 124 | + tag: Tag, | ||
| 125 | + props: HashMap<String, Value>, | ||
| 126 | + children: Vec<u32>, | ||
| 127 | + /// 0 when unparented. The root's parent is 0 as well, which is what stops | ||
| 128 | + /// the ancestor walk in [`Tree::would_cycle`]. | ||
| 129 | + parent: u32, | ||
| 130 | +} | ||
| 131 | + | ||
| 132 | +/// A node's props, copied out for the duration of one measure or paint. | ||
| 133 | +/// | ||
| 134 | +/// Reading them through this rather than the map means a missing prop and a | ||
| 135 | +/// prop of the wrong type answer the same thing: the default. Nothing a caller | ||
| 136 | +/// can write should be able to make a widget vanish. | ||
| 137 | +#[derive(Clone, Debug, Default)] | ||
| 138 | +pub struct Props(pub HashMap<String, Value>); | ||
| 139 | + | ||
| 140 | +impl Props { | ||
| 141 | + pub fn str(&self, key: &str) -> &str { | ||
| 142 | + match self.0.get(key) { | ||
| 143 | + Some(Value::Str(s)) => s, | ||
| 144 | + _ => "", | ||
| 145 | + } | ||
| 146 | + } | ||
| 147 | + | ||
| 148 | + pub fn num(&self, key: &str, fallback: f64) -> f64 { | ||
| 149 | + match self.0.get(key) { | ||
| 150 | + Some(Value::Num(n)) => *n, | ||
| 151 | + Some(Value::Bool(b)) => { | ||
| 152 | + if *b { | ||
| 153 | + 1.0 | ||
| 154 | + } else { | ||
| 155 | + 0.0 | ||
| 156 | + } | ||
| 157 | + } | ||
| 158 | + _ => fallback, | ||
| 159 | + } | ||
| 160 | + } | ||
| 161 | + | ||
| 162 | + /// A count of cells. Negative and absurd values are clamped rather than | ||
| 163 | + /// cast, since `as u16` on a negative double is a silent 0 or 65535. | ||
| 164 | + pub fn cells(&self, key: &str, fallback: u16) -> u16 { | ||
| 165 | + match self.0.get(key) { | ||
| 166 | + Some(Value::Num(n)) if n.is_finite() => n.clamp(0.0, u16::MAX as f64) as u16, | ||
| 167 | + _ => fallback, | ||
| 168 | + } | ||
| 169 | + } | ||
| 170 | + | ||
| 171 | + pub fn bool(&self, key: &str, fallback: bool) -> bool { | ||
| 172 | + match self.0.get(key) { | ||
| 173 | + Some(Value::Bool(b)) => *b, | ||
| 174 | + Some(Value::Num(n)) => *n != 0.0, | ||
| 175 | + Some(Value::Str(s)) => s == "true", | ||
| 176 | + _ => fallback, | ||
| 177 | + } | ||
| 178 | + } | ||
| 179 | + | ||
| 180 | + pub fn has(&self, key: &str) -> bool { | ||
| 181 | + self.0.contains_key(key) | ||
| 182 | + } | ||
| 183 | + | ||
| 184 | + /// The text a widget shows. `:label` and `:text` are the same prop to every | ||
| 185 | + /// glimmer backend; whichever the caller wrote is the one that shows. | ||
| 186 | + pub fn label(&self) -> &str { | ||
| 187 | + if self.has("label") { | ||
| 188 | + self.str("label") | ||
| 189 | + } else { | ||
| 190 | + self.str("text") | ||
| 191 | + } | ||
| 192 | + } | ||
| 193 | +} | ||
| 194 | + | ||
| 195 | +/// One prop value as EDN. Whole numbers print without a trailing `.0`: every | ||
| 196 | +/// number crossed the boundary as a double, and `{:spacing 8}` reads better | ||
| 197 | +/// than `{:spacing 8.0}`. | ||
| 198 | +fn write_value(value: &Value, out: &mut String) { | ||
| 199 | + match value { | ||
| 200 | + Value::Bool(b) => out.push_str(if *b { "true" } else { "false" }), | ||
| 201 | + Value::Num(n) => { | ||
| 202 | + if n.is_finite() && n.fract() == 0.0 && n.abs() < 1e15 { | ||
| 203 | + out.push_str(&format!("{}", *n as i64)); | ||
| 204 | + } else if n.is_finite() { | ||
| 205 | + out.push_str(&format!("{n}")); | ||
| 206 | + } else { | ||
| 207 | + // EDN has no infinity or NaN literal; say nil rather than emit | ||
| 208 | + // something no reader will take. | ||
| 209 | + out.push_str("nil"); | ||
| 210 | + } | ||
| 211 | + } | ||
| 212 | + Value::Str(text) => { | ||
| 213 | + out.push('"'); | ||
| 214 | + for c in text.chars() { | ||
| 215 | + match c { | ||
| 216 | + '"' => out.push_str("\\\""), | ||
| 217 | + '\\' => out.push_str("\\\\"), | ||
| 218 | + '\n' => out.push_str("\\n"), | ||
| 219 | + '\r' => out.push_str("\\r"), | ||
| 220 | + '\t' => out.push_str("\\t"), | ||
| 221 | + _ => out.push(c), | ||
| 222 | + } | ||
| 223 | + } | ||
| 224 | + out.push('"'); | ||
| 225 | + } | ||
| 226 | + } | ||
| 227 | +} | ||
| 228 | + | ||
| 229 | +pub struct Tree { | ||
| 230 | + /// Index 0 is never handed out: 0 is "no node" throughout the ABI. | ||
| 231 | + nodes: Vec<Option<Node>>, | ||
| 232 | + free: Vec<u32>, | ||
| 233 | + root: u32, | ||
| 234 | + pending: VecDeque<Event>, | ||
| 235 | + current: Option<Event>, | ||
| 236 | +} | ||
| 237 | + | ||
| 238 | +impl Default for Tree { | ||
| 239 | + fn default() -> Self { | ||
| 240 | + Self::new() | ||
| 241 | + } | ||
| 242 | +} | ||
| 243 | + | ||
| 244 | +impl Tree { | ||
| 245 | + pub fn new() -> Self { | ||
| 246 | + let mut tree = Self { | ||
| 247 | + nodes: vec![None], | ||
| 248 | + free: Vec::new(), | ||
| 249 | + root: 0, | ||
| 250 | + pending: VecDeque::new(), | ||
| 251 | + current: None, | ||
| 252 | + }; | ||
| 253 | + tree.root = tree.new_node("window"); | ||
| 254 | + tree | ||
| 255 | + } | ||
| 256 | + | ||
| 257 | + pub fn root(&self) -> u32 { | ||
| 258 | + self.root | ||
| 259 | + } | ||
| 260 | + | ||
| 261 | + fn slot(&self, id: u32) -> Option<&Node> { | ||
| 262 | + self.nodes.get(id as usize).and_then(|n| n.as_ref()) | ||
| 263 | + } | ||
| 264 | + | ||
| 265 | + fn slot_mut(&mut self, id: u32) -> Option<&mut Node> { | ||
| 266 | + self.nodes.get_mut(id as usize).and_then(|n| n.as_mut()) | ||
| 267 | + } | ||
| 268 | + | ||
| 269 | + pub fn exists(&self, id: u32) -> bool { | ||
| 270 | + self.slot(id).is_some() | ||
| 271 | + } | ||
| 272 | + | ||
| 273 | + pub fn new_node(&mut self, tag: &str) -> u32 { | ||
| 274 | + let node = Node { | ||
| 275 | + tag: Tag::parse(tag), | ||
| 276 | + ..Node::default() | ||
| 277 | + }; | ||
| 278 | + match self.free.pop() { | ||
| 279 | + Some(id) => { | ||
| 280 | + self.nodes[id as usize] = Some(node); | ||
| 281 | + id | ||
| 282 | + } | ||
| 283 | + None => { | ||
| 284 | + self.nodes.push(Some(node)); | ||
| 285 | + (self.nodes.len() - 1) as u32 | ||
| 286 | + } | ||
| 287 | + } | ||
| 288 | + } | ||
| 289 | + | ||
| 290 | + /// Free `id` and everything under it. The root is refused: the window node | ||
| 291 | + /// is the one thing a caller cannot drop out from under itself. | ||
| 292 | + pub fn free_node(&mut self, id: u32) { | ||
| 293 | + if id == self.root || !self.exists(id) { | ||
| 294 | + return; | ||
| 295 | + } | ||
| 296 | + let parent = self.slot(id).map(|n| n.parent).unwrap_or(0); | ||
| 297 | + if parent != 0 { | ||
| 298 | + if let Some(node) = self.slot_mut(parent) { | ||
| 299 | + node.children.retain(|c| *c != id); | ||
| 300 | + } | ||
| 301 | + } | ||
| 302 | + self.free_subtree(id); | ||
| 303 | + } | ||
| 304 | + | ||
| 305 | + fn free_subtree(&mut self, id: u32) { | ||
| 306 | + let children = self | ||
| 307 | + .slot(id) | ||
| 308 | + .map(|n| n.children.clone()) | ||
| 309 | + .unwrap_or_default(); | ||
| 310 | + for child in children { | ||
| 311 | + self.free_subtree(child); | ||
| 312 | + } | ||
| 313 | + if self.nodes[id as usize].take().is_some() { | ||
| 314 | + self.free.push(id); | ||
| 315 | + } | ||
| 316 | + // An event queued against a node that has since gone would be routed to | ||
| 317 | + // a handler the reconciler has already dropped. Drop it here instead. | ||
| 318 | + self.pending.retain(|e| e.node != id); | ||
| 319 | + } | ||
| 320 | + | ||
| 321 | + /// Whether making `child` a child of `parent` would make a loop — `child` | ||
| 322 | + /// being `parent` or one of its ancestors. | ||
| 323 | + fn would_cycle(&self, parent: u32, child: u32) -> bool { | ||
| 324 | + let mut at = parent; | ||
| 325 | + while at != 0 { | ||
| 326 | + if at == child { | ||
| 327 | + return true; | ||
| 328 | + } | ||
| 329 | + at = match self.slot(at) { | ||
| 330 | + Some(node) => node.parent, | ||
| 331 | + None => return false, | ||
| 332 | + }; | ||
| 333 | + } | ||
| 334 | + false | ||
| 335 | + } | ||
| 336 | + | ||
| 337 | + fn unparent(&mut self, child: u32) { | ||
| 338 | + let parent = self.slot(child).map(|n| n.parent).unwrap_or(0); | ||
| 339 | + if parent != 0 { | ||
| 340 | + if let Some(node) = self.slot_mut(parent) { | ||
| 341 | + node.children.retain(|c| *c != child); | ||
| 342 | + } | ||
| 343 | + } | ||
| 344 | + if let Some(node) = self.slot_mut(child) { | ||
| 345 | + node.parent = 0; | ||
| 346 | + } | ||
| 347 | + } | ||
| 348 | + | ||
| 349 | + pub fn append(&mut self, parent: u32, child: u32) -> bool { | ||
| 350 | + if !self.exists(parent) || !self.exists(child) || self.would_cycle(parent, child) { | ||
| 351 | + return false; | ||
| 352 | + } | ||
| 353 | + self.unparent(child); | ||
| 354 | + self.slot_mut(parent).unwrap().children.push(child); | ||
| 355 | + self.slot_mut(child).unwrap().parent = parent; | ||
| 356 | + true | ||
| 357 | + } | ||
| 358 | + | ||
| 359 | + /// Unparent *and* free `child`, which is what the reconciler means by | ||
| 360 | + /// remove: a node it has taken out of the tree is a node it has dropped. | ||
| 361 | + pub fn remove(&mut self, parent: u32, child: u32) { | ||
| 362 | + if self.slot(child).map(|n| n.parent) == Some(parent) { | ||
| 363 | + self.free_node(child); | ||
| 364 | + } | ||
| 365 | + } | ||
| 366 | + | ||
| 367 | + /// Move `child` after `sibling`; `sibling` 0 means the first position. | ||
| 368 | + pub fn insert_after(&mut self, parent: u32, child: u32, sibling: u32) -> bool { | ||
| 369 | + if !self.exists(parent) || !self.exists(child) || self.would_cycle(parent, child) { | ||
| 370 | + return false; | ||
| 371 | + } | ||
| 372 | + if sibling != 0 && self.slot(sibling).map(|n| n.parent) != Some(parent) { | ||
| 373 | + return false; | ||
| 374 | + } | ||
| 375 | + self.unparent(child); | ||
| 376 | + let at = match sibling { | ||
| 377 | + 0 => 0, | ||
| 378 | + _ => { | ||
| 379 | + let children = &self.slot(parent).unwrap().children; | ||
| 380 | + children | ||
| 381 | + .iter() | ||
| 382 | + .position(|c| *c == sibling) | ||
| 383 | + .map_or(0, |i| i + 1) | ||
| 384 | + } | ||
| 385 | + }; | ||
| 386 | + self.slot_mut(parent).unwrap().children.insert(at, child); | ||
| 387 | + self.slot_mut(child).unwrap().parent = parent; | ||
| 388 | + true | ||
| 389 | + } | ||
| 390 | + | ||
| 391 | + /// Put `new` where `old` was, and free `old`. | ||
| 392 | + pub fn replace(&mut self, parent: u32, old: u32, new: u32) -> bool { | ||
| 393 | + if self.slot(old).map(|n| n.parent) != Some(parent) || !self.exists(new) { | ||
| 394 | + return false; | ||
| 395 | + } | ||
| 396 | + if self.would_cycle(parent, new) { | ||
| 397 | + return false; | ||
| 398 | + } | ||
| 399 | + self.unparent(new); | ||
| 400 | + let at = self | ||
| 401 | + .slot(parent) | ||
| 402 | + .and_then(|n| n.children.iter().position(|c| *c == old)); | ||
| 403 | + let Some(at) = at else { return false }; | ||
| 404 | + self.slot_mut(parent).unwrap().children[at] = new; | ||
| 405 | + self.slot_mut(new).unwrap().parent = parent; | ||
| 406 | + if let Some(node) = self.slot_mut(old) { | ||
| 407 | + node.parent = 0; | ||
| 408 | + } | ||
| 409 | + self.free_subtree(old); | ||
| 410 | + true | ||
| 411 | + } | ||
| 412 | + | ||
| 413 | + // ── reading it back ───────────────────────────────────────────────────── | ||
| 414 | + | ||
| 415 | + pub fn tag(&self, id: u32) -> Tag { | ||
| 416 | + self.slot(id).map(|n| n.tag.clone()).unwrap_or_default() | ||
| 417 | + } | ||
| 418 | + | ||
| 419 | + pub fn tag_name(&self, id: u32) -> &str { | ||
| 420 | + self.slot(id).map_or("", |n| n.tag.name()) | ||
| 421 | + } | ||
| 422 | + | ||
| 423 | + pub fn children(&self, id: u32) -> Vec<u32> { | ||
| 424 | + self.slot(id) | ||
| 425 | + .map(|n| n.children.clone()) | ||
| 426 | + .unwrap_or_default() | ||
| 427 | + } | ||
| 428 | + | ||
| 429 | + pub fn child_count(&self, id: u32) -> usize { | ||
| 430 | + self.slot(id).map_or(0, |n| n.children.len()) | ||
| 431 | + } | ||
| 432 | + | ||
| 433 | + pub fn child_at(&self, id: u32, index: usize) -> u32 { | ||
| 434 | + self.slot(id) | ||
| 435 | + .and_then(|n| n.children.get(index).copied()) | ||
| 436 | + .unwrap_or(0) | ||
| 437 | + } | ||
| 438 | + | ||
| 439 | + pub fn parent(&self, id: u32) -> u32 { | ||
| 440 | + self.slot(id).map_or(0, |n| n.parent) | ||
| 441 | + } | ||
| 442 | + | ||
| 443 | + pub fn props(&self, id: u32) -> Props { | ||
| 444 | + Props(self.slot(id).map(|n| n.props.clone()).unwrap_or_default()) | ||
| 445 | + } | ||
| 446 | + | ||
| 447 | + pub fn set(&mut self, id: u32, key: &str, value: Value) { | ||
| 448 | + if let Some(node) = self.slot_mut(id) { | ||
| 449 | + node.props.insert(key.to_owned(), value); | ||
| 450 | + } | ||
| 451 | + } | ||
| 452 | + | ||
| 453 | + pub fn clear_props(&mut self, id: u32) { | ||
| 454 | + if let Some(node) = self.slot_mut(id) { | ||
| 455 | + node.props.clear(); | ||
| 456 | + } | ||
| 457 | + } | ||
| 458 | + | ||
| 459 | + pub fn get(&self, id: u32, key: &str) -> Option<&Value> { | ||
| 460 | + self.slot(id).and_then(|n| n.props.get(key)) | ||
| 461 | + } | ||
| 462 | + | ||
| 463 | + /// The subtree at `id` as pretty-printed hiccup — what the tree *is*, read | ||
| 464 | + /// back from the arena, rather than what a component meant to build. | ||
| 465 | + /// | ||
| 466 | + /// Props are sorted, so two dumps of the same tree compare as text. | ||
| 467 | + pub fn dump(&self, id: u32) -> String { | ||
| 468 | + let mut out = String::new(); | ||
| 469 | + self.dump_into(id, 0, &mut out); | ||
| 470 | + out | ||
| 471 | + } | ||
| 472 | + | ||
| 473 | + fn dump_into(&self, id: u32, depth: usize, out: &mut String) { | ||
| 474 | + let Some(node) = self.slot(id) else { | ||
| 475 | + out.push_str("nil"); | ||
| 476 | + return; | ||
| 477 | + }; | ||
| 478 | + let indent = " ".repeat(depth); | ||
| 479 | + out.push_str("[:"); | ||
| 480 | + out.push_str(node.tag.name()); | ||
| 481 | + | ||
| 482 | + let mut keys: Vec<&String> = node.props.keys().collect(); | ||
| 483 | + keys.sort(); | ||
| 484 | + out.push_str(" {"); | ||
| 485 | + for (i, key) in keys.iter().enumerate() { | ||
| 486 | + if i > 0 { | ||
| 487 | + out.push(' '); | ||
| 488 | + } | ||
| 489 | + out.push(':'); | ||
| 490 | + out.push_str(key); | ||
| 491 | + out.push(' '); | ||
| 492 | + write_value(&node.props[*key], out); | ||
| 493 | + } | ||
| 494 | + out.push('}'); | ||
| 495 | + | ||
| 496 | + for child in &node.children { | ||
| 497 | + out.push('\n'); | ||
| 498 | + out.push_str(&indent); | ||
| 499 | + out.push_str(" "); | ||
| 500 | + self.dump_into(*child, depth + 1, out); | ||
| 501 | + } | ||
| 502 | + out.push(']'); | ||
| 503 | + } | ||
| 504 | + | ||
| 505 | + // ── events ────────────────────────────────────────────────────────────── | ||
| 506 | + | ||
| 507 | + pub fn emit(&mut self, node: u32, name: &'static str, text: String, num: f64) { | ||
| 508 | + self.pending.push_back(Event { | ||
| 509 | + node, | ||
| 510 | + name, | ||
| 511 | + text, | ||
| 512 | + num, | ||
| 513 | + }); | ||
| 514 | + } | ||
| 515 | + | ||
| 516 | + /// Dequeue one event into the accessor slot. False when the queue is empty. | ||
| 517 | + pub fn poll(&mut self) -> bool { | ||
| 518 | + self.current = self.pending.pop_front(); | ||
| 519 | + self.current.is_some() | ||
| 520 | + } | ||
| 521 | + | ||
| 522 | + pub fn current(&self) -> Option<&Event> { | ||
| 523 | + self.current.as_ref() | ||
| 524 | + } | ||
| 525 | +} | ||
| 526 | + | ||
| 527 | +#[cfg(test)] | ||
| 528 | +mod tests { | ||
| 529 | + use super::*; | ||
| 530 | + | ||
| 531 | + fn tree_with_button() -> (Tree, u32) { | ||
| 532 | + let mut tree = Tree::new(); | ||
| 533 | + let button = tree.new_node("button"); | ||
| 534 | + tree.set(button, "label", Value::Str("go".into())); | ||
| 535 | + let root = tree.root(); | ||
| 536 | + tree.append(root, button); | ||
| 537 | + (tree, button) | ||
| 538 | + } | ||
| 539 | + | ||
| 540 | + #[test] | ||
| 541 | + fn a_dump_is_the_tree_as_hiccup_with_sorted_props() { | ||
| 542 | + let (mut tree, button) = tree_with_button(); | ||
| 543 | + tree.set(button, "kind", Value::Str("primary".into())); | ||
| 544 | + assert_eq!( | ||
| 545 | + tree.dump(tree.root()), | ||
| 546 | + "[:window {}\n [:button {:kind \"primary\" :label \"go\"}]]" | ||
| 547 | + ); | ||
| 548 | + } | ||
| 549 | + | ||
| 550 | + #[test] | ||
| 551 | + fn hbox_and_vbox_are_one_node() { | ||
| 552 | + let mut tree = Tree::new(); | ||
| 553 | + let h = tree.new_node("hbox"); | ||
| 554 | + let v = tree.new_node("vbox"); | ||
| 555 | + assert_eq!(tree.tag_name(h), "box"); | ||
| 556 | + assert_eq!(tree.tag_name(v), "box"); | ||
| 557 | + } | ||
| 558 | + | ||
| 559 | + #[test] | ||
| 560 | + fn an_unknown_tag_keeps_its_name() { | ||
| 561 | + let mut tree = Tree::new(); | ||
| 562 | + let node = tree.new_node("sparkline"); | ||
| 563 | + assert_eq!(tree.tag_name(node), "sparkline"); | ||
| 564 | + assert_eq!(tree.tag(node), Tag::Unknown("sparkline".into())); | ||
| 565 | + } | ||
| 566 | + | ||
| 567 | + #[test] | ||
| 568 | + fn removing_a_node_frees_its_subtree_and_reuses_the_handles() { | ||
| 569 | + let mut tree = Tree::new(); | ||
| 570 | + let outer = tree.new_node("vbox"); | ||
| 571 | + let inner = tree.new_node("label"); | ||
| 572 | + tree.append(outer, inner); | ||
| 573 | + tree.append(tree.root(), outer); | ||
| 574 | + tree.remove(tree.root(), outer); | ||
| 575 | + assert!(!tree.exists(outer)); | ||
| 576 | + assert!(!tree.exists(inner)); | ||
| 577 | + assert_eq!(tree.child_count(tree.root()), 0); | ||
| 578 | + // The arena hands the slots back out rather than growing forever. | ||
| 579 | + assert!([outer, inner].contains(&tree.new_node("label"))); | ||
| 580 | + } | ||
| 581 | + | ||
| 582 | + #[test] | ||
| 583 | + fn a_node_cannot_become_its_own_ancestor() { | ||
| 584 | + let mut tree = Tree::new(); | ||
| 585 | + let outer = tree.new_node("vbox"); | ||
| 586 | + let inner = tree.new_node("vbox"); | ||
| 587 | + tree.append(outer, inner); | ||
| 588 | + assert!(!tree.append(inner, outer)); | ||
| 589 | + assert_eq!(tree.parent(outer), 0); | ||
| 590 | + } | ||
| 591 | + | ||
| 592 | + #[test] | ||
| 593 | + fn insert_after_zero_is_the_first_position() { | ||
| 594 | + let mut tree = Tree::new(); | ||
| 595 | + let (a, b, c) = ( | ||
| 596 | + tree.new_node("label"), | ||
| 597 | + tree.new_node("label"), | ||
| 598 | + tree.new_node("label"), | ||
| 599 | + ); | ||
| 600 | + let root = tree.root(); | ||
| 601 | + tree.append(root, a); | ||
| 602 | + tree.append(root, b); | ||
| 603 | + tree.insert_after(root, c, 0); | ||
| 604 | + assert_eq!(tree.children(root), vec![c, a, b]); | ||
| 605 | + tree.insert_after(root, c, a); | ||
| 606 | + assert_eq!(tree.children(root), vec![a, c, b]); | ||
| 607 | + } | ||
| 608 | + | ||
| 609 | + #[test] | ||
| 610 | + fn replace_keeps_the_position_and_frees_the_old_node() { | ||
| 611 | + let mut tree = Tree::new(); | ||
| 612 | + let root = tree.root(); | ||
| 613 | + let (a, b) = (tree.new_node("label"), tree.new_node("label")); | ||
| 614 | + tree.append(root, a); | ||
| 615 | + tree.append(root, b); | ||
| 616 | + let fresh = tree.new_node("button"); | ||
| 617 | + assert!(tree.replace(root, a, fresh)); | ||
| 618 | + assert_eq!(tree.children(root), vec![fresh, b]); | ||
| 619 | + assert!(!tree.exists(a)); | ||
| 620 | + } | ||
| 621 | + | ||
| 622 | + #[test] | ||
| 623 | + fn the_root_cannot_be_freed() { | ||
| 624 | + let mut tree = Tree::new(); | ||
| 625 | + let root = tree.root(); | ||
| 626 | + tree.free_node(root); | ||
| 627 | + assert!(tree.exists(root)); | ||
| 628 | + } | ||
| 629 | + | ||
| 630 | + #[test] | ||
| 631 | + fn an_event_for_a_freed_node_never_reaches_the_caller() { | ||
| 632 | + let (mut tree, button) = tree_with_button(); | ||
| 633 | + tree.emit(button, "click", String::new(), 0.0); | ||
| 634 | + tree.remove(tree.root(), button); | ||
| 635 | + assert!(!tree.poll()); | ||
| 636 | + } | ||
| 637 | + | ||
| 638 | + #[test] | ||
| 639 | + fn props_of_the_wrong_type_read_as_the_default() { | ||
| 640 | + let mut tree = Tree::new(); | ||
| 641 | + let node = tree.new_node("progress"); | ||
| 642 | + tree.set(node, "value", Value::Str("lots".into())); | ||
| 643 | + let props = tree.props(node); | ||
| 644 | + assert_eq!(props.num("value", 0.5), 0.5); | ||
| 645 | + assert_eq!(props.cells("width-request", 7), 7); | ||
| 646 | + } | ||
| 647 | + | ||
| 648 | + #[test] | ||
| 649 | + fn label_and_text_are_the_same_prop() { | ||
| 650 | + let mut tree = Tree::new(); | ||
| 651 | + let node = tree.new_node("label"); | ||
| 652 | + tree.set(node, "text", Value::Str("hello".into())); | ||
| 653 | + assert_eq!(tree.props(node).label(), "hello"); | ||
| 654 | + tree.set(node, "label", Value::Str("hi".into())); | ||
| 655 | + assert_eq!(tree.props(node).label(), "hi"); | ||
| 656 | + } | ||
| 657 | +} | ||
added
crates/jolt-tui/src/ui.rs +411 -0 | new file mode 100644 | ||
| @@ -0,0 +1,411 @@ | ||
| 1 | +//! The session: a tree, a screen, and where the focus and the caret are. | |
| 2 | +//! | |
| 3 | +//! This is the whole backend minus the terminal. It paints into a grid, takes | |
| 4 | +//! keys and clicks by name, and answers events — so the entire widget layer, | |
| 5 | +//! keyboard navigation included, runs in a test with no TTY, no raw mode and no | |
| 6 | +//! display. `tui_headless` opens exactly this and nothing else. | |
| 7 | +//! | |
| 8 | +//! Keys arrive already named (`"ctrl+u"`, `"page-down"`, `"a"`); turning a | |
| 9 | +//! terminal's bytes into those names is [`crate::keys`]'s job, and a caller | |
| 10 | +//! synthesising one for a test writes the name directly. | |
| 11 | + | |
| 12 | +use crate::keys; | |
| 13 | +use crate::paint::{self, Painted}; | |
| 14 | +use crate::screen::Screen; | |
| 15 | +use crate::tree::{Tag, Tree, Value}; | |
| 16 | + | |
| 17 | +pub struct Ui { | |
| 18 | + pub tree: Tree, | |
| 19 | + pub screen: Screen, | |
| 20 | + /// The node the focus ring is on, 0 for none. | |
| 21 | + focus: u32, | |
| 22 | + /// The caret in the focused entry, in characters from the start. | |
| 23 | + caret: usize, | |
| 24 | + painted: Painted, | |
| 25 | + tick: u64, | |
| 26 | + quit: bool, | |
| 27 | +} | |
| 28 | + | |
| 29 | +impl Ui { | |
| 30 | + pub fn new(width: u16, height: u16) -> Self { | |
| 31 | + Self { | |
| 32 | + tree: Tree::new(), | |
| 33 | + screen: Screen::new(width.max(1), height.max(1)), | |
| 34 | + focus: 0, | |
| 35 | + caret: 0, | |
| 36 | + painted: Painted::default(), | |
| 37 | + tick: 0, | |
| 38 | + quit: false, | |
| 39 | + } | |
| 40 | + } | |
| 41 | + | |
| 42 | + pub fn resize(&mut self, width: u16, height: u16) { | |
| 43 | + self.screen.resize(width.max(1), height.max(1)); | |
| 44 | + } | |
| 45 | + | |
| 46 | + pub fn should_close(&self) -> bool { | |
| 47 | + self.quit | |
| 48 | + } | |
| 49 | + | |
| 50 | + pub fn quit(&mut self) { | |
| 51 | + self.quit = true; | |
| 52 | + } | |
| 53 | + | |
| 54 | + pub fn focus(&self) -> u32 { | |
| 55 | + self.focus | |
| 56 | + } | |
| 57 | + | |
| 58 | + pub fn cursor(&self) -> Option<(u16, u16)> { | |
| 59 | + self.painted.cursor | |
| 60 | + } | |
| 61 | + | |
| 62 | + /// Paint one frame, then settle the things painting decided: what is | |
| 63 | + /// focusable now, and how far each scroll area really is. | |
| 64 | + pub fn frame(&mut self) { | |
| 65 | + self.tick = self.tick.wrapping_add(1); | |
| 66 | + self.paint_once(); | |
| 67 | + if self.settle_focus() { | |
| 68 | + // Focus is decided by what the paint found, so the frame that | |
| 69 | + // gives it away has to be drawn again — otherwise the first frame | |
| 70 | + // of a screen shows nothing focused and the second one does. | |
| 71 | + self.paint_once(); | |
| 72 | + } | |
| 73 | + for (node, offset) in self.painted.scrolled.clone() { | |
| 74 | + // Painting clamps the viewport to the content; write the clamped | |
| 75 | + // value back so the caller's next `+1` starts from the truth. | |
| 76 | + if self.tree.props(node).cells("offset", 0) != offset { | |
| 77 | + self.tree.set(node, "offset", Value::Num(offset as f64)); | |
| 78 | + } | |
| 79 | + } | |
| 80 | + } | |
| 81 | + | |
| 82 | + fn paint_once(&mut self) { | |
| 83 | + self.painted = paint::frame( | |
| 84 | + &self.tree, | |
| 85 | + &mut self.screen, | |
| 86 | + self.focus, | |
| 87 | + self.caret, | |
| 88 | + self.tick, | |
| 89 | + ); | |
| 90 | + } | |
| 91 | + | |
| 92 | + /// Put the focus somewhere real. Answers whether it moved. | |
| 93 | + fn settle_focus(&mut self) -> bool { | |
| 94 | + let was = self.focus; | |
| 95 | + // A focused widget that has since been unmounted — or dimmed — leaves | |
| 96 | + // the ring, and focus lands on the first thing that is still there | |
| 97 | + // rather than on nothing. | |
| 98 | + if self.focus != 0 && !self.painted.ring.contains(&self.focus) { | |
| 99 | + self.focus = 0; | |
| 100 | + } | |
| 101 | + if self.focus == 0 { | |
| 102 | + let wants = self | |
| 103 | + .painted | |
| 104 | + .ring | |
| 105 | + .iter() | |
| 106 | + .find(|id| self.tree.props(**id).bool("autofocus", false)) | |
| 107 | + .copied(); | |
| 108 | + if let Some(id) = wants.or_else(|| self.painted.ring.first().copied()) { | |
| 109 | + self.set_focus(id); | |
| 110 | + } | |
| 111 | + } | |
| 112 | + self.focus != was | |
| 113 | + } | |
| 114 | + | |
| 115 | + fn set_focus(&mut self, id: u32) { | |
| 116 | + if self.focus == id { | |
| 117 | + return; | |
| 118 | + } | |
| 119 | + self.focus = id; | |
| 120 | + // The caret goes to the end of whatever it just entered, which is where | |
| 121 | + // someone tabbing into a field with text in it expects to type. | |
| 122 | + self.caret = self.tree.props(id).str("text").chars().count(); | |
| 123 | + } | |
| 124 | + | |
| 125 | + fn move_focus(&mut self, forward: bool) { | |
| 126 | + if self.painted.ring.is_empty() { | |
| 127 | + return; | |
| 128 | + } | |
| 129 | + let ring = self.painted.ring.clone(); | |
| 130 | + let at = ring.iter().position(|id| *id == self.focus); | |
| 131 | + let next = match (at, forward) { | |
| 132 | + (Some(i), true) => (i + 1) % ring.len(), | |
| 133 | + (Some(i), false) => (i + ring.len() - 1) % ring.len(), | |
| 134 | + (None, true) => 0, | |
| 135 | + (None, false) => ring.len() - 1, | |
| 136 | + }; | |
| 137 | + self.set_focus(ring[next]); | |
| 138 | + } | |
| 139 | + | |
| 140 | + // ── keys ──────────────────────────────────────────────────────────────── | |
| 141 | + | |
| 142 | + /// Handle one key by name. Answers false when nothing here wanted it, in | |
| 143 | + /// which case it has been emitted as a `key` event for the caller to route. | |
| 144 | + pub fn key(&mut self, name: &str) -> bool { | |
| 145 | + if matches!(name, "ctrl+c" | "ctrl+q") { | |
| 146 | + self.quit = true; | |
| 147 | + return true; | |
| 148 | + } | |
| 149 | + match name { | |
| 150 | + "tab" => { | |
| 151 | + self.move_focus(true); | |
| 152 | + return true; | |
| 153 | + } | |
| 154 | + "shift+tab" | "backtab" => { | |
| 155 | + self.move_focus(false); | |
| 156 | + return true; | |
| 157 | + } | |
| 158 | + "esc" => { | |
| 159 | + // Esc belongs to the topmost overlay when there is one: that is | |
| 160 | + // what closes a modal everywhere else. | |
| 161 | + if let Some(overlay) = self.topmost_overlay() { | |
| 162 | + self.tree.emit(overlay, "close", String::new(), 0.0); | |
| 163 | + return true; | |
| 164 | + } | |
| 165 | + } | |
| 166 | + _ => {} | |
| 167 | + } | |
| 168 | + | |
| 169 | + let focus = self.focus; | |
| 170 | + let handled = match self.tree.tag(focus) { | |
| 171 | + Tag::Entry => self.entry_key(focus, name), | |
| 172 | + Tag::Button => self.activate_key(focus, name, "click"), | |
| 173 | + Tag::CheckButton => { | |
| 174 | + if matches!(name, "enter" | "space") { | |
| 175 | + self.toggle(focus); | |
| 176 | + true | |
| 177 | + } else { | |
| 178 | + false | |
| 179 | + } | |
| 180 | + } | |
| 181 | + Tag::Listbox => self.listbox_key(focus, name), | |
| 182 | + _ => false, | |
| 183 | + }; | |
| 184 | + if !handled { | |
| 185 | + // Unhandled keys go to the caller as an event on the focused node, | |
| 186 | + // or on the window when nothing has focus. glimmer bubbles from | |
| 187 | + // there; it holds the handlers and knows the tree. | |
| 188 | + let target = if focus != 0 { focus } else { self.tree.root() }; | |
| 189 | + self.tree.emit(target, "key", name.to_owned(), 0.0); | |
| 190 | + } | |
| 191 | + handled | |
| 192 | + } | |
| 193 | + | |
| 194 | + fn topmost_overlay(&self) -> Option<u32> { | |
| 195 | + fn walk(tree: &Tree, id: u32, found: &mut Option<u32>) { | |
| 196 | + if matches!(tree.tag(id), Tag::Overlay) { | |
| 197 | + *found = Some(id); | |
| 198 | + } | |
| 199 | + for child in tree.children(id) { | |
| 200 | + walk(tree, child, found); | |
| 201 | + } | |
| 202 | + } | |
| 203 | + let mut found = None; | |
| 204 | + walk(&self.tree, self.tree.root(), &mut found); | |
| 205 | + found | |
| 206 | + } | |
| 207 | + | |
| 208 | + fn activate_key(&mut self, node: u32, name: &str, event: &'static str) -> bool { | |
| 209 | + if matches!(name, "enter" | "space") { | |
| 210 | + self.tree.emit(node, event, String::new(), 0.0); | |
| 211 | + true | |
| 212 | + } else { | |
| 213 | + false | |
| 214 | + } | |
| 215 | + } | |
| 216 | + | |
| 217 | + fn toggle(&mut self, node: u32) { | |
| 218 | + let now = !self.tree.props(node).bool("active", false); | |
| 219 | + // The widget does not own its value, but it does keep working when the | |
| 220 | + // caller ignores the event: the new state is written back here, and the | |
| 221 | + // next prop write from the reconciler is what settles it. | |
| 222 | + self.tree.set(node, "active", Value::Bool(now)); | |
| 223 | + self.tree | |
| 224 | + .emit(node, "toggled", String::new(), if now { 1.0 } else { 0.0 }); | |
| 225 | + } | |
| 226 | + | |
| 227 | + fn entry_key(&mut self, node: u32, name: &str) -> bool { | |
| 228 | + let mut text: Vec<char> = self.tree.props(node).str("text").chars().collect(); | |
| 229 | + let mut at = self.caret.min(text.len()); | |
| 230 | + let mut changed = false; | |
| 231 | + match name { | |
| 232 | + "enter" => { | |
| 233 | + let now: String = text.iter().collect(); | |
| 234 | + self.tree.emit(node, "activate", now, 0.0); | |
| 235 | + return true; | |
| 236 | + } | |
| 237 | + "left" | "ctrl+b" => at = at.saturating_sub(1), | |
| 238 | + "right" | "ctrl+f" => at = (at + 1).min(text.len()), | |
| 239 | + "home" | "ctrl+a" => at = 0, | |
| 240 | + "end" | "ctrl+e" => at = text.len(), | |
| 241 | + "alt+b" => at = keys::word_left(&text, at), | |
| 242 | + "alt+f" => at = keys::word_right(&text, at), | |
| 243 | + "backspace" => { | |
| 244 | + if at > 0 { | |
| 245 | + text.remove(at - 1); | |
| 246 | + at -= 1; | |
| 247 | + changed = true; | |
| 248 | + } | |
| 249 | + } | |
| 250 | + "delete" | "ctrl+d" => { | |
| 251 | + if at < text.len() { | |
| 252 | + text.remove(at); | |
| 253 | + changed = true; | |
| 254 | + } | |
| 255 | + } | |
| 256 | + "ctrl+w" | "alt+backspace" => { | |
| 257 | + let from = keys::word_left(&text, at); | |
| 258 | + if from < at { | |
| 259 | + text.drain(from..at); | |
| 260 | + at = from; | |
| 261 | + changed = true; | |
| 262 | + } | |
| 263 | + } | |
| 264 | + "ctrl+u" => { | |
| 265 | + if at > 0 { | |
| 266 | + text.drain(0..at); | |
| 267 | + at = 0; | |
| 268 | + changed = true; | |
| 269 | + } | |
| 270 | + } | |
| 271 | + "ctrl+k" => { | |
| 272 | + if at < text.len() { | |
| 273 | + text.truncate(at); | |
| 274 | + changed = true; | |
| 275 | + } | |
| 276 | + } | |
| 277 | + "space" => { | |
| 278 | + text.insert(at, ' '); | |
| 279 | + at += 1; | |
| 280 | + changed = true; | |
| 281 | + } | |
| 282 | + other => { | |
| 283 | + // A single character with no modifier on it is text. | |
| 284 | + let mut chars = other.chars(); | |
| 285 | + match (chars.next(), chars.next()) { | |
| 286 | + (Some(ch), None) if !ch.is_control() => { | |
| 287 | + text.insert(at, ch); | |
| 288 | + at += 1; | |
| 289 | + changed = true; | |
| 290 | + } | |
| 291 | + _ => return false, | |
| 292 | + } | |
| 293 | + } | |
| 294 | + } | |
| 295 | + self.caret = at; | |
| 296 | + if changed { | |
| 297 | + let now: String = text.iter().collect(); | |
| 298 | + self.tree.set(node, "text", Value::Str(now.clone())); | |
| 299 | + self.tree.emit(node, "change", now, 0.0); | |
| 300 | + } | |
| 301 | + true | |
| 302 | + } | |
| 303 | + | |
| 304 | + fn listbox_key(&mut self, node: u32, name: &str) -> bool { | |
| 305 | + let count = self.tree.child_count(node) as i64; | |
| 306 | + if count == 0 { | |
| 307 | + return false; | |
| 308 | + } | |
| 309 | + let page = self | |
| 310 | + .painted | |
| 311 | + .hits | |
| 312 | + .iter() | |
| 313 | + .find(|(id, _)| *id == node) | |
| 314 | + .map_or(1, |(_, rect)| rect.h.max(1) as i64); | |
| 315 | + let at = self.tree.props(node).num("selected", 0.0) as i64; | |
| 316 | + let to = match name { | |
| 317 | + "down" | "j" | "ctrl+n" => at + 1, | |
| 318 | + "up" | "k" | "ctrl+p" => at - 1, | |
| 319 | + "page-down" | "ctrl+d" => at + page, | |
| 320 | + "page-up" | "ctrl+u" => at - page, | |
| 321 | + "home" | "g" => 0, | |
| 322 | + "end" | "G" => count - 1, | |
| 323 | + "enter" | "space" => { | |
| 324 | + let index = at.clamp(0, count - 1); | |
| 325 | + let item = self.tree.child_at(node, index as usize); | |
| 326 | + let label = self.tree.props(item).label().to_owned(); | |
| 327 | + self.tree.emit(node, "activate", label, index as f64); | |
| 328 | + return true; | |
| 329 | + } | |
| 330 | + _ => return false, | |
| 331 | + }; | |
| 332 | + self.select(node, to.clamp(0, count - 1)); | |
| 333 | + true | |
| 334 | + } | |
| 335 | + | |
| 336 | + fn select(&mut self, node: u32, index: i64) { | |
| 337 | + if self.tree.props(node).num("selected", -1.0) as i64 == index { | |
| 338 | + return; | |
| 339 | + } | |
| 340 | + self.tree.set(node, "selected", Value::Num(index as f64)); | |
| 341 | + let item = self.tree.child_at(node, index as usize); | |
| 342 | + let label = self.tree.props(item).label().to_owned(); | |
| 343 | + self.tree.emit(node, "select", label, index as f64); | |
| 344 | + } | |
| 345 | + | |
| 346 | + // ── mouse ─────────────────────────────────────────────────────────────── | |
| 347 | + | |
| 348 | + /// A click at a cell. Focuses whatever is under it and activates it, which | |
| 349 | + /// is the whole of button 1 in a terminal: there is no press and release to | |
| 350 | + /// tell apart at this level. | |
| 351 | + pub fn click(&mut self, x: u16, y: u16) -> bool { | |
| 352 | + let Some((node, rect)) = self | |
| 353 | + .painted | |
| 354 | + .hits | |
| 355 | + .iter() | |
| 356 | + .find(|(_, rect)| rect.contains(x, y)) | |
| 357 | + .copied() | |
| 358 | + else { | |
| 359 | + return false; | |
| 360 | + }; | |
| 361 | + self.set_focus(node); | |
| 362 | + match self.tree.tag(node) { | |
| 363 | + Tag::Button => self.tree.emit(node, "click", String::new(), 0.0), | |
| 364 | + Tag::CheckButton => self.toggle(node), | |
| 365 | + Tag::Listbox => { | |
| 366 | + let row = (y - rect.y) as i64; | |
| 367 | + let count = self.tree.child_count(node) as i64; | |
| 368 | + if count > 0 { | |
| 369 | + self.select(node, row.clamp(0, count - 1)); | |
| 370 | + } | |
| 371 | + } | |
| 372 | + Tag::Entry => { | |
| 373 | + // Put the caret where it was clicked, not at the end. | |
| 374 | + let text = self.tree.props(node).str("text").chars().count(); | |
| 375 | + self.caret = ((x - rect.x) as usize).min(text); | |
| 376 | + } | |
| 377 | + _ => {} | |
| 378 | + } | |
| 379 | + true | |
| 380 | + } | |
| 381 | + | |
| 382 | + /// The wheel, `by` rows — negative is up. It moves the innermost `:scroll` | |
| 383 | + /// under the pointer, which is the one a reader means. | |
| 384 | + pub fn wheel(&mut self, x: u16, y: u16, by: i32) -> bool { | |
| 385 | + let Some(node) = self.scroll_at(self.tree.root(), x, y) else { | |
| 386 | + return false; | |
| 387 | + }; | |
| 388 | + let now = self.tree.props(node).cells("offset", 0) as i32; | |
| 389 | + let to = (now + by).max(0) as f64; | |
| 390 | + self.tree.set(node, "offset", Value::Num(to)); | |
| 391 | + self.tree.emit(node, "scroll", String::new(), to); | |
| 392 | + true | |
| 393 | + } | |
| 394 | + | |
| 395 | + /// The innermost `:scroll` whose painted area holds this cell. | |
| 396 | + fn scroll_at(&self, id: u32, x: u16, y: u16) -> Option<u32> { | |
| 397 | + for child in self.tree.children(id) { | |
| 398 | + if let Some(inner) = self.scroll_at(child, x, y) { | |
| 399 | + return Some(inner); | |
| 400 | + } | |
| 401 | + } | |
| 402 | + // Scroll areas take no focus, so they are not in the hit list; the | |
| 403 | + // frame records the ones it painted, which is enough for a wheel. | |
| 404 | + let painted = self.painted.scrolled.iter().any(|(n, _)| *n == id); | |
| 405 | + if painted && matches!(self.tree.tag(id), Tag::Scroll) && self.screen.rect().contains(x, y) | |
| 406 | + { | |
| 407 | + return Some(id); | |
| 408 | + } | |
| 409 | + None | |
| 410 | + } | |
| 411 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,411 @@ | |||
| 1 | +//! The session: a tree, a screen, and where the focus and the caret are. | ||
| 2 | +//! | ||
| 3 | +//! This is the whole backend minus the terminal. It paints into a grid, takes | ||
| 4 | +//! keys and clicks by name, and answers events — so the entire widget layer, | ||
| 5 | +//! keyboard navigation included, runs in a test with no TTY, no raw mode and no | ||
| 6 | +//! display. `tui_headless` opens exactly this and nothing else. | ||
| 7 | +//! | ||
| 8 | +//! Keys arrive already named (`"ctrl+u"`, `"page-down"`, `"a"`); turning a | ||
| 9 | +//! terminal's bytes into those names is [`crate::keys`]'s job, and a caller | ||
| 10 | +//! synthesising one for a test writes the name directly. | ||
| 11 | + | ||
| 12 | +use crate::keys; | ||
| 13 | +use crate::paint::{self, Painted}; | ||
| 14 | +use crate::screen::Screen; | ||
| 15 | +use crate::tree::{Tag, Tree, Value}; | ||
| 16 | + | ||
| 17 | +pub struct Ui { | ||
| 18 | + pub tree: Tree, | ||
| 19 | + pub screen: Screen, | ||
| 20 | + /// The node the focus ring is on, 0 for none. | ||
| 21 | + focus: u32, | ||
| 22 | + /// The caret in the focused entry, in characters from the start. | ||
| 23 | + caret: usize, | ||
| 24 | + painted: Painted, | ||
| 25 | + tick: u64, | ||
| 26 | + quit: bool, | ||
| 27 | +} | ||
| 28 | + | ||
| 29 | +impl Ui { | ||
| 30 | + pub fn new(width: u16, height: u16) -> Self { | ||
| 31 | + Self { | ||
| 32 | + tree: Tree::new(), | ||
| 33 | + screen: Screen::new(width.max(1), height.max(1)), | ||
| 34 | + focus: 0, | ||
| 35 | + caret: 0, | ||
| 36 | + painted: Painted::default(), | ||
| 37 | + tick: 0, | ||
| 38 | + quit: false, | ||
| 39 | + } | ||
| 40 | + } | ||
| 41 | + | ||
| 42 | + pub fn resize(&mut self, width: u16, height: u16) { | ||
| 43 | + self.screen.resize(width.max(1), height.max(1)); | ||
| 44 | + } | ||
| 45 | + | ||
| 46 | + pub fn should_close(&self) -> bool { | ||
| 47 | + self.quit | ||
| 48 | + } | ||
| 49 | + | ||
| 50 | + pub fn quit(&mut self) { | ||
| 51 | + self.quit = true; | ||
| 52 | + } | ||
| 53 | + | ||
| 54 | + pub fn focus(&self) -> u32 { | ||
| 55 | + self.focus | ||
| 56 | + } | ||
| 57 | + | ||
| 58 | + pub fn cursor(&self) -> Option<(u16, u16)> { | ||
| 59 | + self.painted.cursor | ||
| 60 | + } | ||
| 61 | + | ||
| 62 | + /// Paint one frame, then settle the things painting decided: what is | ||
| 63 | + /// focusable now, and how far each scroll area really is. | ||
| 64 | + pub fn frame(&mut self) { | ||
| 65 | + self.tick = self.tick.wrapping_add(1); | ||
| 66 | + self.paint_once(); | ||
| 67 | + if self.settle_focus() { | ||
| 68 | + // Focus is decided by what the paint found, so the frame that | ||
| 69 | + // gives it away has to be drawn again — otherwise the first frame | ||
| 70 | + // of a screen shows nothing focused and the second one does. | ||
| 71 | + self.paint_once(); | ||
| 72 | + } | ||
| 73 | + for (node, offset) in self.painted.scrolled.clone() { | ||
| 74 | + // Painting clamps the viewport to the content; write the clamped | ||
| 75 | + // value back so the caller's next `+1` starts from the truth. | ||
| 76 | + if self.tree.props(node).cells("offset", 0) != offset { | ||
| 77 | + self.tree.set(node, "offset", Value::Num(offset as f64)); | ||
| 78 | + } | ||
| 79 | + } | ||
| 80 | + } | ||
| 81 | + | ||
| 82 | + fn paint_once(&mut self) { | ||
| 83 | + self.painted = paint::frame( | ||
| 84 | + &self.tree, | ||
| 85 | + &mut self.screen, | ||
| 86 | + self.focus, | ||
| 87 | + self.caret, | ||
| 88 | + self.tick, | ||
| 89 | + ); | ||
| 90 | + } | ||
| 91 | + | ||
| 92 | + /// Put the focus somewhere real. Answers whether it moved. | ||
| 93 | + fn settle_focus(&mut self) -> bool { | ||
| 94 | + let was = self.focus; | ||
| 95 | + // A focused widget that has since been unmounted — or dimmed — leaves | ||
| 96 | + // the ring, and focus lands on the first thing that is still there | ||
| 97 | + // rather than on nothing. | ||
| 98 | + if self.focus != 0 && !self.painted.ring.contains(&self.focus) { | ||
| 99 | + self.focus = 0; | ||
| 100 | + } | ||
| 101 | + if self.focus == 0 { | ||
| 102 | + let wants = self | ||
| 103 | + .painted | ||
| 104 | + .ring | ||
| 105 | + .iter() | ||
| 106 | + .find(|id| self.tree.props(**id).bool("autofocus", false)) | ||
| 107 | + .copied(); | ||
| 108 | + if let Some(id) = wants.or_else(|| self.painted.ring.first().copied()) { | ||
| 109 | + self.set_focus(id); | ||
| 110 | + } | ||
| 111 | + } | ||
| 112 | + self.focus != was | ||
| 113 | + } | ||
| 114 | + | ||
| 115 | + fn set_focus(&mut self, id: u32) { | ||
| 116 | + if self.focus == id { | ||
| 117 | + return; | ||
| 118 | + } | ||
| 119 | + self.focus = id; | ||
| 120 | + // The caret goes to the end of whatever it just entered, which is where | ||
| 121 | + // someone tabbing into a field with text in it expects to type. | ||
| 122 | + self.caret = self.tree.props(id).str("text").chars().count(); | ||
| 123 | + } | ||
| 124 | + | ||
| 125 | + fn move_focus(&mut self, forward: bool) { | ||
| 126 | + if self.painted.ring.is_empty() { | ||
| 127 | + return; | ||
| 128 | + } | ||
| 129 | + let ring = self.painted.ring.clone(); | ||
| 130 | + let at = ring.iter().position(|id| *id == self.focus); | ||
| 131 | + let next = match (at, forward) { | ||
| 132 | + (Some(i), true) => (i + 1) % ring.len(), | ||
| 133 | + (Some(i), false) => (i + ring.len() - 1) % ring.len(), | ||
| 134 | + (None, true) => 0, | ||
| 135 | + (None, false) => ring.len() - 1, | ||
| 136 | + }; | ||
| 137 | + self.set_focus(ring[next]); | ||
| 138 | + } | ||
| 139 | + | ||
| 140 | + // ── keys ──────────────────────────────────────────────────────────────── | ||
| 141 | + | ||
| 142 | + /// Handle one key by name. Answers false when nothing here wanted it, in | ||
| 143 | + /// which case it has been emitted as a `key` event for the caller to route. | ||
| 144 | + pub fn key(&mut self, name: &str) -> bool { | ||
| 145 | + if matches!(name, "ctrl+c" | "ctrl+q") { | ||
| 146 | + self.quit = true; | ||
| 147 | + return true; | ||
| 148 | + } | ||
| 149 | + match name { | ||
| 150 | + "tab" => { | ||
| 151 | + self.move_focus(true); | ||
| 152 | + return true; | ||
| 153 | + } | ||
| 154 | + "shift+tab" | "backtab" => { | ||
| 155 | + self.move_focus(false); | ||
| 156 | + return true; | ||
| 157 | + } | ||
| 158 | + "esc" => { | ||
| 159 | + // Esc belongs to the topmost overlay when there is one: that is | ||
| 160 | + // what closes a modal everywhere else. | ||
| 161 | + if let Some(overlay) = self.topmost_overlay() { | ||
| 162 | + self.tree.emit(overlay, "close", String::new(), 0.0); | ||
| 163 | + return true; | ||
| 164 | + } | ||
| 165 | + } | ||
| 166 | + _ => {} | ||
| 167 | + } | ||
| 168 | + | ||
| 169 | + let focus = self.focus; | ||
| 170 | + let handled = match self.tree.tag(focus) { | ||
| 171 | + Tag::Entry => self.entry_key(focus, name), | ||
| 172 | + Tag::Button => self.activate_key(focus, name, "click"), | ||
| 173 | + Tag::CheckButton => { | ||
| 174 | + if matches!(name, "enter" | "space") { | ||
| 175 | + self.toggle(focus); | ||
| 176 | + true | ||
| 177 | + } else { | ||
| 178 | + false | ||
| 179 | + } | ||
| 180 | + } | ||
| 181 | + Tag::Listbox => self.listbox_key(focus, name), | ||
| 182 | + _ => false, | ||
| 183 | + }; | ||
| 184 | + if !handled { | ||
| 185 | + // Unhandled keys go to the caller as an event on the focused node, | ||
| 186 | + // or on the window when nothing has focus. glimmer bubbles from | ||
| 187 | + // there; it holds the handlers and knows the tree. | ||
| 188 | + let target = if focus != 0 { focus } else { self.tree.root() }; | ||
| 189 | + self.tree.emit(target, "key", name.to_owned(), 0.0); | ||
| 190 | + } | ||
| 191 | + handled | ||
| 192 | + } | ||
| 193 | + | ||
| 194 | + fn topmost_overlay(&self) -> Option<u32> { | ||
| 195 | + fn walk(tree: &Tree, id: u32, found: &mut Option<u32>) { | ||
| 196 | + if matches!(tree.tag(id), Tag::Overlay) { | ||
| 197 | + *found = Some(id); | ||
| 198 | + } | ||
| 199 | + for child in tree.children(id) { | ||
| 200 | + walk(tree, child, found); | ||
| 201 | + } | ||
| 202 | + } | ||
| 203 | + let mut found = None; | ||
| 204 | + walk(&self.tree, self.tree.root(), &mut found); | ||
| 205 | + found | ||
| 206 | + } | ||
| 207 | + | ||
| 208 | + fn activate_key(&mut self, node: u32, name: &str, event: &'static str) -> bool { | ||
| 209 | + if matches!(name, "enter" | "space") { | ||
| 210 | + self.tree.emit(node, event, String::new(), 0.0); | ||
| 211 | + true | ||
| 212 | + } else { | ||
| 213 | + false | ||
| 214 | + } | ||
| 215 | + } | ||
| 216 | + | ||
| 217 | + fn toggle(&mut self, node: u32) { | ||
| 218 | + let now = !self.tree.props(node).bool("active", false); | ||
| 219 | + // The widget does not own its value, but it does keep working when the | ||
| 220 | + // caller ignores the event: the new state is written back here, and the | ||
| 221 | + // next prop write from the reconciler is what settles it. | ||
| 222 | + self.tree.set(node, "active", Value::Bool(now)); | ||
| 223 | + self.tree | ||
| 224 | + .emit(node, "toggled", String::new(), if now { 1.0 } else { 0.0 }); | ||
| 225 | + } | ||
| 226 | + | ||
| 227 | + fn entry_key(&mut self, node: u32, name: &str) -> bool { | ||
| 228 | + let mut text: Vec<char> = self.tree.props(node).str("text").chars().collect(); | ||
| 229 | + let mut at = self.caret.min(text.len()); | ||
| 230 | + let mut changed = false; | ||
| 231 | + match name { | ||
| 232 | + "enter" => { | ||
| 233 | + let now: String = text.iter().collect(); | ||
| 234 | + self.tree.emit(node, "activate", now, 0.0); | ||
| 235 | + return true; | ||
| 236 | + } | ||
| 237 | + "left" | "ctrl+b" => at = at.saturating_sub(1), | ||
| 238 | + "right" | "ctrl+f" => at = (at + 1).min(text.len()), | ||
| 239 | + "home" | "ctrl+a" => at = 0, | ||
| 240 | + "end" | "ctrl+e" => at = text.len(), | ||
| 241 | + "alt+b" => at = keys::word_left(&text, at), | ||
| 242 | + "alt+f" => at = keys::word_right(&text, at), | ||
| 243 | + "backspace" => { | ||
| 244 | + if at > 0 { | ||
| 245 | + text.remove(at - 1); | ||
| 246 | + at -= 1; | ||
| 247 | + changed = true; | ||
| 248 | + } | ||
| 249 | + } | ||
| 250 | + "delete" | "ctrl+d" => { | ||
| 251 | + if at < text.len() { | ||
| 252 | + text.remove(at); | ||
| 253 | + changed = true; | ||
| 254 | + } | ||
| 255 | + } | ||
| 256 | + "ctrl+w" | "alt+backspace" => { | ||
| 257 | + let from = keys::word_left(&text, at); | ||
| 258 | + if from < at { | ||
| 259 | + text.drain(from..at); | ||
| 260 | + at = from; | ||
| 261 | + changed = true; | ||
| 262 | + } | ||
| 263 | + } | ||
| 264 | + "ctrl+u" => { | ||
| 265 | + if at > 0 { | ||
| 266 | + text.drain(0..at); | ||
| 267 | + at = 0; | ||
| 268 | + changed = true; | ||
| 269 | + } | ||
| 270 | + } | ||
| 271 | + "ctrl+k" => { | ||
| 272 | + if at < text.len() { | ||
| 273 | + text.truncate(at); | ||
| 274 | + changed = true; | ||
| 275 | + } | ||
| 276 | + } | ||
| 277 | + "space" => { | ||
| 278 | + text.insert(at, ' '); | ||
| 279 | + at += 1; | ||
| 280 | + changed = true; | ||
| 281 | + } | ||
| 282 | + other => { | ||
| 283 | + // A single character with no modifier on it is text. | ||
| 284 | + let mut chars = other.chars(); | ||
| 285 | + match (chars.next(), chars.next()) { | ||
| 286 | + (Some(ch), None) if !ch.is_control() => { | ||
| 287 | + text.insert(at, ch); | ||
| 288 | + at += 1; | ||
| 289 | + changed = true; | ||
| 290 | + } | ||
| 291 | + _ => return false, | ||
| 292 | + } | ||
| 293 | + } | ||
| 294 | + } | ||
| 295 | + self.caret = at; | ||
| 296 | + if changed { | ||
| 297 | + let now: String = text.iter().collect(); | ||
| 298 | + self.tree.set(node, "text", Value::Str(now.clone())); | ||
| 299 | + self.tree.emit(node, "change", now, 0.0); | ||
| 300 | + } | ||
| 301 | + true | ||
| 302 | + } | ||
| 303 | + | ||
| 304 | + fn listbox_key(&mut self, node: u32, name: &str) -> bool { | ||
| 305 | + let count = self.tree.child_count(node) as i64; | ||
| 306 | + if count == 0 { | ||
| 307 | + return false; | ||
| 308 | + } | ||
| 309 | + let page = self | ||
| 310 | + .painted | ||
| 311 | + .hits | ||
| 312 | + .iter() | ||
| 313 | + .find(|(id, _)| *id == node) | ||
| 314 | + .map_or(1, |(_, rect)| rect.h.max(1) as i64); | ||
| 315 | + let at = self.tree.props(node).num("selected", 0.0) as i64; | ||
| 316 | + let to = match name { | ||
| 317 | + "down" | "j" | "ctrl+n" => at + 1, | ||
| 318 | + "up" | "k" | "ctrl+p" => at - 1, | ||
| 319 | + "page-down" | "ctrl+d" => at + page, | ||
| 320 | + "page-up" | "ctrl+u" => at - page, | ||
| 321 | + "home" | "g" => 0, | ||
| 322 | + "end" | "G" => count - 1, | ||
| 323 | + "enter" | "space" => { | ||
| 324 | + let index = at.clamp(0, count - 1); | ||
| 325 | + let item = self.tree.child_at(node, index as usize); | ||
| 326 | + let label = self.tree.props(item).label().to_owned(); | ||
| 327 | + self.tree.emit(node, "activate", label, index as f64); | ||
| 328 | + return true; | ||
| 329 | + } | ||
| 330 | + _ => return false, | ||
| 331 | + }; | ||
| 332 | + self.select(node, to.clamp(0, count - 1)); | ||
| 333 | + true | ||
| 334 | + } | ||
| 335 | + | ||
| 336 | + fn select(&mut self, node: u32, index: i64) { | ||
| 337 | + if self.tree.props(node).num("selected", -1.0) as i64 == index { | ||
| 338 | + return; | ||
| 339 | + } | ||
| 340 | + self.tree.set(node, "selected", Value::Num(index as f64)); | ||
| 341 | + let item = self.tree.child_at(node, index as usize); | ||
| 342 | + let label = self.tree.props(item).label().to_owned(); | ||
| 343 | + self.tree.emit(node, "select", label, index as f64); | ||
| 344 | + } | ||
| 345 | + | ||
| 346 | + // ── mouse ─────────────────────────────────────────────────────────────── | ||
| 347 | + | ||
| 348 | + /// A click at a cell. Focuses whatever is under it and activates it, which | ||
| 349 | + /// is the whole of button 1 in a terminal: there is no press and release to | ||
| 350 | + /// tell apart at this level. | ||
| 351 | + pub fn click(&mut self, x: u16, y: u16) -> bool { | ||
| 352 | + let Some((node, rect)) = self | ||
| 353 | + .painted | ||
| 354 | + .hits | ||
| 355 | + .iter() | ||
| 356 | + .find(|(_, rect)| rect.contains(x, y)) | ||
| 357 | + .copied() | ||
| 358 | + else { | ||
| 359 | + return false; | ||
| 360 | + }; | ||
| 361 | + self.set_focus(node); | ||
| 362 | + match self.tree.tag(node) { | ||
| 363 | + Tag::Button => self.tree.emit(node, "click", String::new(), 0.0), | ||
| 364 | + Tag::CheckButton => self.toggle(node), | ||
| 365 | + Tag::Listbox => { | ||
| 366 | + let row = (y - rect.y) as i64; | ||
| 367 | + let count = self.tree.child_count(node) as i64; | ||
| 368 | + if count > 0 { | ||
| 369 | + self.select(node, row.clamp(0, count - 1)); | ||
| 370 | + } | ||
| 371 | + } | ||
| 372 | + Tag::Entry => { | ||
| 373 | + // Put the caret where it was clicked, not at the end. | ||
| 374 | + let text = self.tree.props(node).str("text").chars().count(); | ||
| 375 | + self.caret = ((x - rect.x) as usize).min(text); | ||
| 376 | + } | ||
| 377 | + _ => {} | ||
| 378 | + } | ||
| 379 | + true | ||
| 380 | + } | ||
| 381 | + | ||
| 382 | + /// The wheel, `by` rows — negative is up. It moves the innermost `:scroll` | ||
| 383 | + /// under the pointer, which is the one a reader means. | ||
| 384 | + pub fn wheel(&mut self, x: u16, y: u16, by: i32) -> bool { | ||
| 385 | + let Some(node) = self.scroll_at(self.tree.root(), x, y) else { | ||
| 386 | + return false; | ||
| 387 | + }; | ||
| 388 | + let now = self.tree.props(node).cells("offset", 0) as i32; | ||
| 389 | + let to = (now + by).max(0) as f64; | ||
| 390 | + self.tree.set(node, "offset", Value::Num(to)); | ||
| 391 | + self.tree.emit(node, "scroll", String::new(), to); | ||
| 392 | + true | ||
| 393 | + } | ||
| 394 | + | ||
| 395 | + /// The innermost `:scroll` whose painted area holds this cell. | ||
| 396 | + fn scroll_at(&self, id: u32, x: u16, y: u16) -> Option<u32> { | ||
| 397 | + for child in self.tree.children(id) { | ||
| 398 | + if let Some(inner) = self.scroll_at(child, x, y) { | ||
| 399 | + return Some(inner); | ||
| 400 | + } | ||
| 401 | + } | ||
| 402 | + // Scroll areas take no focus, so they are not in the hit list; the | ||
| 403 | + // frame records the ones it painted, which is enough for a wheel. | ||
| 404 | + let painted = self.painted.scrolled.iter().any(|(n, _)| *n == id); | ||
| 405 | + if painted && matches!(self.tree.tag(id), Tag::Scroll) && self.screen.rect().contains(x, y) | ||
| 406 | + { | ||
| 407 | + return Some(id); | ||
| 408 | + } | ||
| 409 | + None | ||
| 410 | + } | ||
| 411 | +} | ||
modified
justfile +1 -0 | @@ -43,6 +43,7 @@ buck-build *args: | ||
| 43 | 43 | mkdir -p build/lib |
| 44 | 44 | cp "$(just buck build --show-output //:libvidya | awk '/libvidya.so/{print $2}')" build/lib/libvidya.so |
| 45 | 45 | cp "$(just buck build --show-output //:libjoltmoq | awk '/libjoltmoq.so/{print $2}')" build/lib/libjoltmoq.so |
| 46 | + cp "$(just buck build --show-output //:libjolttui | awk '/libjolttui.so/{print $2}')" build/lib/libjolttui.so | |
| 46 | 47 | @echo "{{justfile_directory()}}/build/lib" |
| 47 | 48 | |
| 48 | 49 | # Where a consumer points LD_LIBRARY_PATH for the buck2 build. |
| @@ -43,6 +43,7 @@ buck-build *args: | |||
| 43 | mkdir -p build/lib | 43 | mkdir -p build/lib |
| 44 | cp "$(just buck build --show-output //:libvidya | awk '/libvidya.so/{print $2}')" build/lib/libvidya.so | 44 | cp "$(just buck build --show-output //:libvidya | awk '/libvidya.so/{print $2}')" build/lib/libvidya.so |
| 45 | cp "$(just buck build --show-output //:libjoltmoq | awk '/libjoltmoq.so/{print $2}')" build/lib/libjoltmoq.so | 45 | cp "$(just buck build --show-output //:libjoltmoq | awk '/libjoltmoq.so/{print $2}')" build/lib/libjoltmoq.so |
| 46 | + cp "$(just buck build --show-output //:libjolttui | awk '/libjolttui.so/{print $2}')" build/lib/libjolttui.so | ||
| 46 | @echo "{{justfile_directory()}}/build/lib" | 47 | @echo "{{justfile_directory()}}/build/lib" |
| 47 | 48 | ||
| 48 | # Where a consumer points LD_LIBRARY_PATH for the buck2 build. | 49 | # Where a consumer points LD_LIBRARY_PATH for the buck2 build. |
modified
third-party/rust/BUCK +276 -50 | @@ -2447,20 +2447,7 @@ cargo.rust_library( | ||
| 2447 | 2447 | "CARGO_PKG_VERSION_PATCH": "1", |
| 2448 | 2448 | "CARGO_PKG_VERSION_PRE": "", |
| 2449 | 2449 | }, |
| 2450 | - platform = { | |
| 2451 | - "linux-arm64": dict( | |
| 2452 | - features = ["std"], | |
| 2453 | - ), | |
| 2454 | - "linux-x86_64": dict( | |
| 2455 | - features = ["std"], | |
| 2456 | - ), | |
| 2457 | - "macos-arm64": dict( | |
| 2458 | - features = ["std"], | |
| 2459 | - ), | |
| 2460 | - "macos-x86_64": dict( | |
| 2461 | - features = ["std"], | |
| 2462 | - ), | |
| 2463 | - }, | |
| 2450 | + features = ["std"], | |
| 2464 | 2451 | visibility = [], |
| 2465 | 2452 | ) |
| 2466 | 2453 | |
| @@ -5054,6 +5041,55 @@ buildscript_run( | ||
| 5054 | 5041 | version = "0.8.22", |
| 5055 | 5042 | ) |
| 5056 | 5043 | |
| 5044 | +alias( | |
| 5045 | + name = "crossterm", | |
| 5046 | + actual = ":crossterm-0.28", | |
| 5047 | + visibility = ["PUBLIC"], | |
| 5048 | +) | |
| 5049 | + | |
| 5050 | +http_archive( | |
| 5051 | + name = "crossterm-0.28.1.crate", | |
| 5052 | + sha256 = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6", | |
| 5053 | + strip_prefix = "crossterm-0.28.1", | |
| 5054 | + urls = ["https://static.crates.io/crates/crossterm/0.28.1/download"], | |
| 5055 | + visibility = [], | |
| 5056 | +) | |
| 5057 | + | |
| 5058 | +cargo.rust_library( | |
| 5059 | + name = "crossterm-0.28", | |
| 5060 | + srcs = [":crossterm-0.28.1.crate"], | |
| 5061 | + crate = "crossterm", | |
| 5062 | + crate_root = "crossterm-0.28.1.crate/src/lib.rs", | |
| 5063 | + edition = "2021", | |
| 5064 | + env = { | |
| 5065 | + "CARGO_BIN_NAME": "crossterm", | |
| 5066 | + "CARGO_CRATE_NAME": "crossterm", | |
| 5067 | + "CARGO_MANIFEST_DIR": "crossterm-0.28.1.crate", | |
| 5068 | + "CARGO_PKG_AUTHORS": "T. Post", | |
| 5069 | + "CARGO_PKG_DESCRIPTION": "A crossplatform terminal library for manipulating terminals.", | |
| 5070 | + "CARGO_PKG_HOMEPAGE": "", | |
| 5071 | + "CARGO_PKG_NAME": "crossterm", | |
| 5072 | + "CARGO_PKG_README": "README.md", | |
| 5073 | + "CARGO_PKG_REPOSITORY": "https://github.com/crossterm-rs/crossterm", | |
| 5074 | + "CARGO_PKG_RUST_VERSION": "1.63.0", | |
| 5075 | + "CARGO_PKG_VERSION": "0.28.1", | |
| 5076 | + "CARGO_PKG_VERSION_MAJOR": "0", | |
| 5077 | + "CARGO_PKG_VERSION_MINOR": "28", | |
| 5078 | + "CARGO_PKG_VERSION_PATCH": "1", | |
| 5079 | + "CARGO_PKG_VERSION_PRE": "", | |
| 5080 | + }, | |
| 5081 | + features = ["events"], | |
| 5082 | + visibility = [], | |
| 5083 | + deps = [ | |
| 5084 | + ":bitflags-2", | |
| 5085 | + ":mio-1", | |
| 5086 | + ":parking_lot-0.12", | |
| 5087 | + ":rustix-0.38", | |
| 5088 | + ":signal-hook-0.3", | |
| 5089 | + ":signal-hook-mio-0.2", | |
| 5090 | + ], | |
| 5091 | +) | |
| 5092 | + | |
| 5057 | 5093 | http_archive( |
| 5058 | 5094 | name = "crypto-common-0.1.6.crate", |
| 5059 | 5095 | sha256 = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3", |
| @@ -13211,14 +13247,28 @@ cargo.rust_library( | ||
| 13211 | 13247 | "CARGO_PKG_VERSION_PRE": "", |
| 13212 | 13248 | }, |
| 13213 | 13249 | features = [ |
| 13214 | - "elf", | |
| 13215 | - "errno", | |
| 13216 | 13250 | "general", |
| 13217 | 13251 | "ioctl", |
| 13218 | 13252 | "no_std", |
| 13219 | - "prctl", | |
| 13220 | - "system", | |
| 13221 | 13253 | ], |
| 13254 | + platform = { | |
| 13255 | + "linux-arm64": dict( | |
| 13256 | + features = [ | |
| 13257 | + "elf", | |
| 13258 | + "errno", | |
| 13259 | + "prctl", | |
| 13260 | + "system", | |
| 13261 | + ], | |
| 13262 | + ), | |
| 13263 | + "linux-x86_64": dict( | |
| 13264 | + features = [ | |
| 13265 | + "elf", | |
| 13266 | + "errno", | |
| 13267 | + "prctl", | |
| 13268 | + "system", | |
| 13269 | + ], | |
| 13270 | + ), | |
| 13271 | + }, | |
| 13222 | 13272 | visibility = [], |
| 13223 | 13273 | ) |
| 13224 | 13274 | |
| @@ -13921,12 +13971,17 @@ cargo.rust_library( | ||
| 13921 | 13971 | "CARGO_PKG_VERSION_PRE": "", |
| 13922 | 13972 | }, |
| 13923 | 13973 | features = [ |
| 13974 | + "default", | |
| 13975 | + "log", | |
| 13924 | 13976 | "net", |
| 13925 | 13977 | "os-ext", |
| 13926 | 13978 | "os-poll", |
| 13927 | 13979 | ], |
| 13928 | 13980 | visibility = [], |
| 13929 | - deps = [":libc-0.2"], | |
| 13981 | + deps = [ | |
| 13982 | + ":libc-0.2", | |
| 13983 | + ":log-0.4", | |
| 13984 | + ], | |
| 13930 | 13985 | ) |
| 13931 | 13986 | |
| 13932 | 13987 | http_archive( |
| @@ -21895,24 +21950,65 @@ cargo.rust_library( | ||
| 21895 | 21950 | }, |
| 21896 | 21951 | features = [ |
| 21897 | 21952 | "alloc", |
| 21898 | - "default", | |
| 21899 | - "event", | |
| 21900 | - "fs", | |
| 21901 | 21953 | "libc-extra-traits", |
| 21902 | - "pipe", | |
| 21903 | - "process", | |
| 21904 | - "shm", | |
| 21905 | 21954 | "std", |
| 21906 | - "system", | |
| 21907 | - "thread", | |
| 21908 | - "use-libc-auxv", | |
| 21955 | + "stdio", | |
| 21956 | + "termios", | |
| 21909 | 21957 | ], |
| 21958 | + platform = { | |
| 21959 | + "android-arm64": dict( | |
| 21960 | + named_deps = { | |
| 21961 | + "libc_errno": ":errno-0.3", | |
| 21962 | + }, | |
| 21963 | + deps = [ | |
| 21964 | + ":libc-0.2", | |
| 21965 | + ":linux-raw-sys-0.4", | |
| 21966 | + ], | |
| 21967 | + ), | |
| 21968 | + "linux-arm64": dict( | |
| 21969 | + features = [ | |
| 21970 | + "default", | |
| 21971 | + "event", | |
| 21972 | + "fs", | |
| 21973 | + "pipe", | |
| 21974 | + "process", | |
| 21975 | + "shm", | |
| 21976 | + "system", | |
| 21977 | + "thread", | |
| 21978 | + "use-libc-auxv", | |
| 21979 | + ], | |
| 21980 | + deps = [":linux-raw-sys-0.4"], | |
| 21981 | + ), | |
| 21982 | + "linux-x86_64": dict( | |
| 21983 | + features = [ | |
| 21984 | + "default", | |
| 21985 | + "event", | |
| 21986 | + "fs", | |
| 21987 | + "pipe", | |
| 21988 | + "process", | |
| 21989 | + "shm", | |
| 21990 | + "system", | |
| 21991 | + "thread", | |
| 21992 | + "use-libc-auxv", | |
| 21993 | + ], | |
| 21994 | + deps = [":linux-raw-sys-0.4"], | |
| 21995 | + ), | |
| 21996 | + "macos-arm64": dict( | |
| 21997 | + named_deps = { | |
| 21998 | + "libc_errno": ":errno-0.3", | |
| 21999 | + }, | |
| 22000 | + deps = [":libc-0.2"], | |
| 22001 | + ), | |
| 22002 | + "macos-x86_64": dict( | |
| 22003 | + named_deps = { | |
| 22004 | + "libc_errno": ":errno-0.3", | |
| 22005 | + }, | |
| 22006 | + deps = [":libc-0.2"], | |
| 22007 | + ), | |
| 22008 | + }, | |
| 21910 | 22009 | rustc_flags = ["@$(location :rustix-0.38-build-script-run[rustc_flags])"], |
| 21911 | 22010 | visibility = [], |
| 21912 | - deps = [ | |
| 21913 | - ":bitflags-2", | |
| 21914 | - ":linux-raw-sys-0.4", | |
| 21915 | - ], | |
| 22011 | + deps = [":bitflags-2"], | |
| 21916 | 22012 | ) |
| 21917 | 22013 | |
| 21918 | 22014 | cargo.rust_binary( |
| @@ -21940,18 +22036,39 @@ cargo.rust_binary( | ||
| 21940 | 22036 | }, |
| 21941 | 22037 | features = [ |
| 21942 | 22038 | "alloc", |
| 21943 | - "default", | |
| 21944 | - "event", | |
| 21945 | - "fs", | |
| 21946 | 22039 | "libc-extra-traits", |
| 21947 | - "pipe", | |
| 21948 | - "process", | |
| 21949 | - "shm", | |
| 21950 | 22040 | "std", |
| 21951 | - "system", | |
| 21952 | - "thread", | |
| 21953 | - "use-libc-auxv", | |
| 22041 | + "stdio", | |
| 22042 | + "termios", | |
| 21954 | 22043 | ], |
| 22044 | + platform = { | |
| 22045 | + "linux-arm64": dict( | |
| 22046 | + features = [ | |
| 22047 | + "default", | |
| 22048 | + "event", | |
| 22049 | + "fs", | |
| 22050 | + "pipe", | |
| 22051 | + "process", | |
| 22052 | + "shm", | |
| 22053 | + "system", | |
| 22054 | + "thread", | |
| 22055 | + "use-libc-auxv", | |
| 22056 | + ], | |
| 22057 | + ), | |
| 22058 | + "linux-x86_64": dict( | |
| 22059 | + features = [ | |
| 22060 | + "default", | |
| 22061 | + "event", | |
| 22062 | + "fs", | |
| 22063 | + "pipe", | |
| 22064 | + "process", | |
| 22065 | + "shm", | |
| 22066 | + "system", | |
| 22067 | + "thread", | |
| 22068 | + "use-libc-auxv", | |
| 22069 | + ], | |
| 22070 | + ), | |
| 22071 | + }, | |
| 21955 | 22072 | visibility = [], |
| 21956 | 22073 | ) |
| 21957 | 22074 | |
| @@ -21974,18 +22091,39 @@ buildscript_run( | ||
| 21974 | 22091 | }, |
| 21975 | 22092 | features = [ |
| 21976 | 22093 | "alloc", |
| 21977 | - "default", | |
| 21978 | - "event", | |
| 21979 | - "fs", | |
| 21980 | 22094 | "libc-extra-traits", |
| 21981 | - "pipe", | |
| 21982 | - "process", | |
| 21983 | - "shm", | |
| 21984 | 22095 | "std", |
| 21985 | - "system", | |
| 21986 | - "thread", | |
| 21987 | - "use-libc-auxv", | |
| 22096 | + "stdio", | |
| 22097 | + "termios", | |
| 21988 | 22098 | ], |
| 22099 | + platform = { | |
| 22100 | + "linux-arm64": dict( | |
| 22101 | + features = [ | |
| 22102 | + "default", | |
| 22103 | + "event", | |
| 22104 | + "fs", | |
| 22105 | + "pipe", | |
| 22106 | + "process", | |
| 22107 | + "shm", | |
| 22108 | + "system", | |
| 22109 | + "thread", | |
| 22110 | + "use-libc-auxv", | |
| 22111 | + ], | |
| 22112 | + ), | |
| 22113 | + "linux-x86_64": dict( | |
| 22114 | + features = [ | |
| 22115 | + "default", | |
| 22116 | + "event", | |
| 22117 | + "fs", | |
| 22118 | + "pipe", | |
| 22119 | + "process", | |
| 22120 | + "shm", | |
| 22121 | + "system", | |
| 22122 | + "thread", | |
| 22123 | + "use-libc-auxv", | |
| 22124 | + ], | |
| 22125 | + ), | |
| 22126 | + }, | |
| 21989 | 22127 | version = "0.38.44", |
| 21990 | 22128 | ) |
| 21991 | 22129 | |
| @@ -24204,6 +24342,94 @@ cargo.rust_library( | ||
| 24204 | 24342 | visibility = [], |
| 24205 | 24343 | ) |
| 24206 | 24344 | |
| 24345 | +http_archive( | |
| 24346 | + name = "signal-hook-0.3.18.crate", | |
| 24347 | + sha256 = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2", | |
| 24348 | + strip_prefix = "signal-hook-0.3.18", | |
| 24349 | + urls = ["https://static.crates.io/crates/signal-hook/0.3.18/download"], | |
| 24350 | + visibility = [], | |
| 24351 | +) | |
| 24352 | + | |
| 24353 | +cargo.rust_library( | |
| 24354 | + name = "signal-hook-0.3", | |
| 24355 | + srcs = [":signal-hook-0.3.18.crate"], | |
| 24356 | + crate = "signal_hook", | |
| 24357 | + crate_root = "signal-hook-0.3.18.crate/src/lib.rs", | |
| 24358 | + edition = "2018", | |
| 24359 | + env = { | |
| 24360 | + "CARGO_BIN_NAME": "signal_hook", | |
| 24361 | + "CARGO_CRATE_NAME": "signal_hook", | |
| 24362 | + "CARGO_MANIFEST_DIR": "signal-hook-0.3.18.crate", | |
| 24363 | + "CARGO_PKG_AUTHORS": "Michal 'vorner' Vaner <vorner@vorner.cz>:Thomas Himmelstoss <thimm@posteo.de>", | |
| 24364 | + "CARGO_PKG_DESCRIPTION": "Unix signal handling", | |
| 24365 | + "CARGO_PKG_HOMEPAGE": "", | |
| 24366 | + "CARGO_PKG_NAME": "signal-hook", | |
| 24367 | + "CARGO_PKG_README": "README.md", | |
| 24368 | + "CARGO_PKG_REPOSITORY": "https://github.com/vorner/signal-hook", | |
| 24369 | + "CARGO_PKG_RUST_VERSION": "", | |
| 24370 | + "CARGO_PKG_VERSION": "0.3.18", | |
| 24371 | + "CARGO_PKG_VERSION_MAJOR": "0", | |
| 24372 | + "CARGO_PKG_VERSION_MINOR": "3", | |
| 24373 | + "CARGO_PKG_VERSION_PATCH": "18", | |
| 24374 | + "CARGO_PKG_VERSION_PRE": "", | |
| 24375 | + }, | |
| 24376 | + features = [ | |
| 24377 | + "channel", | |
| 24378 | + "default", | |
| 24379 | + "iterator", | |
| 24380 | + ], | |
| 24381 | + visibility = [], | |
| 24382 | + deps = [ | |
| 24383 | + ":libc-0.2", | |
| 24384 | + ":signal-hook-registry-1", | |
| 24385 | + ], | |
| 24386 | +) | |
| 24387 | + | |
| 24388 | +http_archive( | |
| 24389 | + name = "signal-hook-mio-0.2.5.crate", | |
| 24390 | + sha256 = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc", | |
| 24391 | + strip_prefix = "signal-hook-mio-0.2.5", | |
| 24392 | + urls = ["https://static.crates.io/crates/signal-hook-mio/0.2.5/download"], | |
| 24393 | + visibility = [], | |
| 24394 | +) | |
| 24395 | + | |
| 24396 | +cargo.rust_library( | |
| 24397 | + name = "signal-hook-mio-0.2", | |
| 24398 | + srcs = [":signal-hook-mio-0.2.5.crate"], | |
| 24399 | + crate = "signal_hook_mio", | |
| 24400 | + crate_root = "signal-hook-mio-0.2.5.crate/src/lib.rs", | |
| 24401 | + edition = "2018", | |
| 24402 | + env = { | |
| 24403 | + "CARGO_BIN_NAME": "signal_hook_mio", | |
| 24404 | + "CARGO_CRATE_NAME": "signal_hook_mio", | |
| 24405 | + "CARGO_MANIFEST_DIR": "signal-hook-mio-0.2.5.crate", | |
| 24406 | + "CARGO_PKG_AUTHORS": "Michal 'vorner' Vaner <vorner@vorner.cz>:Thomas Himmelstoss <thimm@posteo.de>", | |
| 24407 | + "CARGO_PKG_DESCRIPTION": "MIO support for signal-hook", | |
| 24408 | + "CARGO_PKG_HOMEPAGE": "", | |
| 24409 | + "CARGO_PKG_NAME": "signal-hook-mio", | |
| 24410 | + "CARGO_PKG_README": "README.md", | |
| 24411 | + "CARGO_PKG_REPOSITORY": "https://github.com/vorner/signal-hook", | |
| 24412 | + "CARGO_PKG_RUST_VERSION": "", | |
| 24413 | + "CARGO_PKG_VERSION": "0.2.5", | |
| 24414 | + "CARGO_PKG_VERSION_MAJOR": "0", | |
| 24415 | + "CARGO_PKG_VERSION_MINOR": "2", | |
| 24416 | + "CARGO_PKG_VERSION_PATCH": "5", | |
| 24417 | + "CARGO_PKG_VERSION_PRE": "", | |
| 24418 | + }, | |
| 24419 | + features = [ | |
| 24420 | + "mio-1_0", | |
| 24421 | + "support-v1_0", | |
| 24422 | + ], | |
| 24423 | + named_deps = { | |
| 24424 | + "mio_1_0": ":mio-1", | |
| 24425 | + }, | |
| 24426 | + visibility = [], | |
| 24427 | + deps = [ | |
| 24428 | + ":libc-0.2", | |
| 24429 | + ":signal-hook-0.3", | |
| 24430 | + ], | |
| 24431 | +) | |
| 24432 | + | |
| 24207 | 24433 | http_archive( |
| 24208 | 24434 | name = "signal-hook-registry-1.4.8.crate", |
| 24209 | 24435 | sha256 = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b", |
| @@ -2447,20 +2447,7 @@ cargo.rust_library( | |||
| 2447 | "CARGO_PKG_VERSION_PATCH": "1", | 2447 | "CARGO_PKG_VERSION_PATCH": "1", |
| 2448 | "CARGO_PKG_VERSION_PRE": "", | 2448 | "CARGO_PKG_VERSION_PRE": "", |
| 2449 | }, | 2449 | }, |
| 2450 | - platform = { | 2450 | + features = ["std"], |
| 2451 | - "linux-arm64": dict( | ||
| 2452 | - features = ["std"], | ||
| 2453 | - ), | ||
| 2454 | - "linux-x86_64": dict( | ||
| 2455 | - features = ["std"], | ||
| 2456 | - ), | ||
| 2457 | - "macos-arm64": dict( | ||
| 2458 | - features = ["std"], | ||
| 2459 | - ), | ||
| 2460 | - "macos-x86_64": dict( | ||
| 2461 | - features = ["std"], | ||
| 2462 | - ), | ||
| 2463 | - }, | ||
| 2464 | visibility = [], | 2451 | visibility = [], |
| 2465 | ) | 2452 | ) |
| 2466 | 2453 | ||
| @@ -5054,6 +5041,55 @@ buildscript_run( | |||
| 5054 | version = "0.8.22", | 5041 | version = "0.8.22", |
| 5055 | ) | 5042 | ) |
| 5056 | 5043 | ||
| 5044 | +alias( | ||
| 5045 | + name = "crossterm", | ||
| 5046 | + actual = ":crossterm-0.28", | ||
| 5047 | + visibility = ["PUBLIC"], | ||
| 5048 | +) | ||
| 5049 | + | ||
| 5050 | +http_archive( | ||
| 5051 | + name = "crossterm-0.28.1.crate", | ||
| 5052 | + sha256 = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6", | ||
| 5053 | + strip_prefix = "crossterm-0.28.1", | ||
| 5054 | + urls = ["https://static.crates.io/crates/crossterm/0.28.1/download"], | ||
| 5055 | + visibility = [], | ||
| 5056 | +) | ||
| 5057 | + | ||
| 5058 | +cargo.rust_library( | ||
| 5059 | + name = "crossterm-0.28", | ||
| 5060 | + srcs = [":crossterm-0.28.1.crate"], | ||
| 5061 | + crate = "crossterm", | ||
| 5062 | + crate_root = "crossterm-0.28.1.crate/src/lib.rs", | ||
| 5063 | + edition = "2021", | ||
| 5064 | + env = { | ||
| 5065 | + "CARGO_BIN_NAME": "crossterm", | ||
| 5066 | + "CARGO_CRATE_NAME": "crossterm", | ||
| 5067 | + "CARGO_MANIFEST_DIR": "crossterm-0.28.1.crate", | ||
| 5068 | + "CARGO_PKG_AUTHORS": "T. Post", | ||
| 5069 | + "CARGO_PKG_DESCRIPTION": "A crossplatform terminal library for manipulating terminals.", | ||
| 5070 | + "CARGO_PKG_HOMEPAGE": "", | ||
| 5071 | + "CARGO_PKG_NAME": "crossterm", | ||
| 5072 | + "CARGO_PKG_README": "README.md", | ||
| 5073 | + "CARGO_PKG_REPOSITORY": "https://github.com/crossterm-rs/crossterm", | ||
| 5074 | + "CARGO_PKG_RUST_VERSION": "1.63.0", | ||
| 5075 | + "CARGO_PKG_VERSION": "0.28.1", | ||
| 5076 | + "CARGO_PKG_VERSION_MAJOR": "0", | ||
| 5077 | + "CARGO_PKG_VERSION_MINOR": "28", | ||
| 5078 | + "CARGO_PKG_VERSION_PATCH": "1", | ||
| 5079 | + "CARGO_PKG_VERSION_PRE": "", | ||
| 5080 | + }, | ||
| 5081 | + features = ["events"], | ||
| 5082 | + visibility = [], | ||
| 5083 | + deps = [ | ||
| 5084 | + ":bitflags-2", | ||
| 5085 | + ":mio-1", | ||
| 5086 | + ":parking_lot-0.12", | ||
| 5087 | + ":rustix-0.38", | ||
| 5088 | + ":signal-hook-0.3", | ||
| 5089 | + ":signal-hook-mio-0.2", | ||
| 5090 | + ], | ||
| 5091 | +) | ||
| 5092 | + | ||
| 5057 | http_archive( | 5093 | http_archive( |
| 5058 | name = "crypto-common-0.1.6.crate", | 5094 | name = "crypto-common-0.1.6.crate", |
| 5059 | sha256 = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3", | 5095 | sha256 = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3", |
| @@ -13211,14 +13247,28 @@ cargo.rust_library( | |||
| 13211 | "CARGO_PKG_VERSION_PRE": "", | 13247 | "CARGO_PKG_VERSION_PRE": "", |
| 13212 | }, | 13248 | }, |
| 13213 | features = [ | 13249 | features = [ |
| 13214 | - "elf", | ||
| 13215 | - "errno", | ||
| 13216 | "general", | 13250 | "general", |
| 13217 | "ioctl", | 13251 | "ioctl", |
| 13218 | "no_std", | 13252 | "no_std", |
| 13219 | - "prctl", | ||
| 13220 | - "system", | ||
| 13221 | ], | 13253 | ], |
| 13254 | + platform = { | ||
| 13255 | + "linux-arm64": dict( | ||
| 13256 | + features = [ | ||
| 13257 | + "elf", | ||
| 13258 | + "errno", | ||
| 13259 | + "prctl", | ||
| 13260 | + "system", | ||
| 13261 | + ], | ||
| 13262 | + ), | ||
| 13263 | + "linux-x86_64": dict( | ||
| 13264 | + features = [ | ||
| 13265 | + "elf", | ||
| 13266 | + "errno", | ||
| 13267 | + "prctl", | ||
| 13268 | + "system", | ||
| 13269 | + ], | ||
| 13270 | + ), | ||
| 13271 | + }, | ||
| 13222 | visibility = [], | 13272 | visibility = [], |
| 13223 | ) | 13273 | ) |
| 13224 | 13274 | ||
| @@ -13921,12 +13971,17 @@ cargo.rust_library( | |||
| 13921 | "CARGO_PKG_VERSION_PRE": "", | 13971 | "CARGO_PKG_VERSION_PRE": "", |
| 13922 | }, | 13972 | }, |
| 13923 | features = [ | 13973 | features = [ |
| 13974 | + "default", | ||
| 13975 | + "log", | ||
| 13924 | "net", | 13976 | "net", |
| 13925 | "os-ext", | 13977 | "os-ext", |
| 13926 | "os-poll", | 13978 | "os-poll", |
| 13927 | ], | 13979 | ], |
| 13928 | visibility = [], | 13980 | visibility = [], |
| 13929 | - deps = [":libc-0.2"], | 13981 | + deps = [ |
| 13982 | + ":libc-0.2", | ||
| 13983 | + ":log-0.4", | ||
| 13984 | + ], | ||
| 13930 | ) | 13985 | ) |
| 13931 | 13986 | ||
| 13932 | http_archive( | 13987 | http_archive( |
| @@ -21895,24 +21950,65 @@ cargo.rust_library( | |||
| 21895 | }, | 21950 | }, |
| 21896 | features = [ | 21951 | features = [ |
| 21897 | "alloc", | 21952 | "alloc", |
| 21898 | - "default", | ||
| 21899 | - "event", | ||
| 21900 | - "fs", | ||
| 21901 | "libc-extra-traits", | 21953 | "libc-extra-traits", |
| 21902 | - "pipe", | ||
| 21903 | - "process", | ||
| 21904 | - "shm", | ||
| 21905 | "std", | 21954 | "std", |
| 21906 | - "system", | 21955 | + "stdio", |
| 21907 | - "thread", | 21956 | + "termios", |
| 21908 | - "use-libc-auxv", | ||
| 21909 | ], | 21957 | ], |
| 21958 | + platform = { | ||
| 21959 | + "android-arm64": dict( | ||
| 21960 | + named_deps = { | ||
| 21961 | + "libc_errno": ":errno-0.3", | ||
| 21962 | + }, | ||
| 21963 | + deps = [ | ||
| 21964 | + ":libc-0.2", | ||
| 21965 | + ":linux-raw-sys-0.4", | ||
| 21966 | + ], | ||
| 21967 | + ), | ||
| 21968 | + "linux-arm64": dict( | ||
| 21969 | + features = [ | ||
| 21970 | + "default", | ||
| 21971 | + "event", | ||
| 21972 | + "fs", | ||
| 21973 | + "pipe", | ||
| 21974 | + "process", | ||
| 21975 | + "shm", | ||
| 21976 | + "system", | ||
| 21977 | + "thread", | ||
| 21978 | + "use-libc-auxv", | ||
| 21979 | + ], | ||
| 21980 | + deps = [":linux-raw-sys-0.4"], | ||
| 21981 | + ), | ||
| 21982 | + "linux-x86_64": dict( | ||
| 21983 | + features = [ | ||
| 21984 | + "default", | ||
| 21985 | + "event", | ||
| 21986 | + "fs", | ||
| 21987 | + "pipe", | ||
| 21988 | + "process", | ||
| 21989 | + "shm", | ||
| 21990 | + "system", | ||
| 21991 | + "thread", | ||
| 21992 | + "use-libc-auxv", | ||
| 21993 | + ], | ||
| 21994 | + deps = [":linux-raw-sys-0.4"], | ||
| 21995 | + ), | ||
| 21996 | + "macos-arm64": dict( | ||
| 21997 | + named_deps = { | ||
| 21998 | + "libc_errno": ":errno-0.3", | ||
| 21999 | + }, | ||
| 22000 | + deps = [":libc-0.2"], | ||
| 22001 | + ), | ||
| 22002 | + "macos-x86_64": dict( | ||
| 22003 | + named_deps = { | ||
| 22004 | + "libc_errno": ":errno-0.3", | ||
| 22005 | + }, | ||
| 22006 | + deps = [":libc-0.2"], | ||
| 22007 | + ), | ||
| 22008 | + }, | ||
| 21910 | rustc_flags = ["@$(location :rustix-0.38-build-script-run[rustc_flags])"], | 22009 | rustc_flags = ["@$(location :rustix-0.38-build-script-run[rustc_flags])"], |
| 21911 | visibility = [], | 22010 | visibility = [], |
| 21912 | - deps = [ | 22011 | + deps = [":bitflags-2"], |
| 21913 | - ":bitflags-2", | ||
| 21914 | - ":linux-raw-sys-0.4", | ||
| 21915 | - ], | ||
| 21916 | ) | 22012 | ) |
| 21917 | 22013 | ||
| 21918 | cargo.rust_binary( | 22014 | cargo.rust_binary( |
| @@ -21940,18 +22036,39 @@ cargo.rust_binary( | |||
| 21940 | }, | 22036 | }, |
| 21941 | features = [ | 22037 | features = [ |
| 21942 | "alloc", | 22038 | "alloc", |
| 21943 | - "default", | ||
| 21944 | - "event", | ||
| 21945 | - "fs", | ||
| 21946 | "libc-extra-traits", | 22039 | "libc-extra-traits", |
| 21947 | - "pipe", | ||
| 21948 | - "process", | ||
| 21949 | - "shm", | ||
| 21950 | "std", | 22040 | "std", |
| 21951 | - "system", | 22041 | + "stdio", |
| 21952 | - "thread", | 22042 | + "termios", |
| 21953 | - "use-libc-auxv", | ||
| 21954 | ], | 22043 | ], |
| 22044 | + platform = { | ||
| 22045 | + "linux-arm64": dict( | ||
| 22046 | + features = [ | ||
| 22047 | + "default", | ||
| 22048 | + "event", | ||
| 22049 | + "fs", | ||
| 22050 | + "pipe", | ||
| 22051 | + "process", | ||
| 22052 | + "shm", | ||
| 22053 | + "system", | ||
| 22054 | + "thread", | ||
| 22055 | + "use-libc-auxv", | ||
| 22056 | + ], | ||
| 22057 | + ), | ||
| 22058 | + "linux-x86_64": dict( | ||
| 22059 | + features = [ | ||
| 22060 | + "default", | ||
| 22061 | + "event", | ||
| 22062 | + "fs", | ||
| 22063 | + "pipe", | ||
| 22064 | + "process", | ||
| 22065 | + "shm", | ||
| 22066 | + "system", | ||
| 22067 | + "thread", | ||
| 22068 | + "use-libc-auxv", | ||
| 22069 | + ], | ||
| 22070 | + ), | ||
| 22071 | + }, | ||
| 21955 | visibility = [], | 22072 | visibility = [], |
| 21956 | ) | 22073 | ) |
| 21957 | 22074 | ||
| @@ -21974,18 +22091,39 @@ buildscript_run( | |||
| 21974 | }, | 22091 | }, |
| 21975 | features = [ | 22092 | features = [ |
| 21976 | "alloc", | 22093 | "alloc", |
| 21977 | - "default", | ||
| 21978 | - "event", | ||
| 21979 | - "fs", | ||
| 21980 | "libc-extra-traits", | 22094 | "libc-extra-traits", |
| 21981 | - "pipe", | ||
| 21982 | - "process", | ||
| 21983 | - "shm", | ||
| 21984 | "std", | 22095 | "std", |
| 21985 | - "system", | 22096 | + "stdio", |
| 21986 | - "thread", | 22097 | + "termios", |
| 21987 | - "use-libc-auxv", | ||
| 21988 | ], | 22098 | ], |
| 22099 | + platform = { | ||
| 22100 | + "linux-arm64": dict( | ||
| 22101 | + features = [ | ||
| 22102 | + "default", | ||
| 22103 | + "event", | ||
| 22104 | + "fs", | ||
| 22105 | + "pipe", | ||
| 22106 | + "process", | ||
| 22107 | + "shm", | ||
| 22108 | + "system", | ||
| 22109 | + "thread", | ||
| 22110 | + "use-libc-auxv", | ||
| 22111 | + ], | ||
| 22112 | + ), | ||
| 22113 | + "linux-x86_64": dict( | ||
| 22114 | + features = [ | ||
| 22115 | + "default", | ||
| 22116 | + "event", | ||
| 22117 | + "fs", | ||
| 22118 | + "pipe", | ||
| 22119 | + "process", | ||
| 22120 | + "shm", | ||
| 22121 | + "system", | ||
| 22122 | + "thread", | ||
| 22123 | + "use-libc-auxv", | ||
| 22124 | + ], | ||
| 22125 | + ), | ||
| 22126 | + }, | ||
| 21989 | version = "0.38.44", | 22127 | version = "0.38.44", |
| 21990 | ) | 22128 | ) |
| 21991 | 22129 | ||
| @@ -24204,6 +24342,94 @@ cargo.rust_library( | |||
| 24204 | visibility = [], | 24342 | visibility = [], |
| 24205 | ) | 24343 | ) |
| 24206 | 24344 | ||
| 24345 | +http_archive( | ||
| 24346 | + name = "signal-hook-0.3.18.crate", | ||
| 24347 | + sha256 = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2", | ||
| 24348 | + strip_prefix = "signal-hook-0.3.18", | ||
| 24349 | + urls = ["https://static.crates.io/crates/signal-hook/0.3.18/download"], | ||
| 24350 | + visibility = [], | ||
| 24351 | +) | ||
| 24352 | + | ||
| 24353 | +cargo.rust_library( | ||
| 24354 | + name = "signal-hook-0.3", | ||
| 24355 | + srcs = [":signal-hook-0.3.18.crate"], | ||
| 24356 | + crate = "signal_hook", | ||
| 24357 | + crate_root = "signal-hook-0.3.18.crate/src/lib.rs", | ||
| 24358 | + edition = "2018", | ||
| 24359 | + env = { | ||
| 24360 | + "CARGO_BIN_NAME": "signal_hook", | ||
| 24361 | + "CARGO_CRATE_NAME": "signal_hook", | ||
| 24362 | + "CARGO_MANIFEST_DIR": "signal-hook-0.3.18.crate", | ||
| 24363 | + "CARGO_PKG_AUTHORS": "Michal 'vorner' Vaner <vorner@vorner.cz>:Thomas Himmelstoss <thimm@posteo.de>", | ||
| 24364 | + "CARGO_PKG_DESCRIPTION": "Unix signal handling", | ||
| 24365 | + "CARGO_PKG_HOMEPAGE": "", | ||
| 24366 | + "CARGO_PKG_NAME": "signal-hook", | ||
| 24367 | + "CARGO_PKG_README": "README.md", | ||
| 24368 | + "CARGO_PKG_REPOSITORY": "https://github.com/vorner/signal-hook", | ||
| 24369 | + "CARGO_PKG_RUST_VERSION": "", | ||
| 24370 | + "CARGO_PKG_VERSION": "0.3.18", | ||
| 24371 | + "CARGO_PKG_VERSION_MAJOR": "0", | ||
| 24372 | + "CARGO_PKG_VERSION_MINOR": "3", | ||
| 24373 | + "CARGO_PKG_VERSION_PATCH": "18", | ||
| 24374 | + "CARGO_PKG_VERSION_PRE": "", | ||
| 24375 | + }, | ||
| 24376 | + features = [ | ||
| 24377 | + "channel", | ||
| 24378 | + "default", | ||
| 24379 | + "iterator", | ||
| 24380 | + ], | ||
| 24381 | + visibility = [], | ||
| 24382 | + deps = [ | ||
| 24383 | + ":libc-0.2", | ||
| 24384 | + ":signal-hook-registry-1", | ||
| 24385 | + ], | ||
| 24386 | +) | ||
| 24387 | + | ||
| 24388 | +http_archive( | ||
| 24389 | + name = "signal-hook-mio-0.2.5.crate", | ||
| 24390 | + sha256 = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc", | ||
| 24391 | + strip_prefix = "signal-hook-mio-0.2.5", | ||
| 24392 | + urls = ["https://static.crates.io/crates/signal-hook-mio/0.2.5/download"], | ||
| 24393 | + visibility = [], | ||
| 24394 | +) | ||
| 24395 | + | ||
| 24396 | +cargo.rust_library( | ||
| 24397 | + name = "signal-hook-mio-0.2", | ||
| 24398 | + srcs = [":signal-hook-mio-0.2.5.crate"], | ||
| 24399 | + crate = "signal_hook_mio", | ||
| 24400 | + crate_root = "signal-hook-mio-0.2.5.crate/src/lib.rs", | ||
| 24401 | + edition = "2018", | ||
| 24402 | + env = { | ||
| 24403 | + "CARGO_BIN_NAME": "signal_hook_mio", | ||
| 24404 | + "CARGO_CRATE_NAME": "signal_hook_mio", | ||
| 24405 | + "CARGO_MANIFEST_DIR": "signal-hook-mio-0.2.5.crate", | ||
| 24406 | + "CARGO_PKG_AUTHORS": "Michal 'vorner' Vaner <vorner@vorner.cz>:Thomas Himmelstoss <thimm@posteo.de>", | ||
| 24407 | + "CARGO_PKG_DESCRIPTION": "MIO support for signal-hook", | ||
| 24408 | + "CARGO_PKG_HOMEPAGE": "", | ||
| 24409 | + "CARGO_PKG_NAME": "signal-hook-mio", | ||
| 24410 | + "CARGO_PKG_README": "README.md", | ||
| 24411 | + "CARGO_PKG_REPOSITORY": "https://github.com/vorner/signal-hook", | ||
| 24412 | + "CARGO_PKG_RUST_VERSION": "", | ||
| 24413 | + "CARGO_PKG_VERSION": "0.2.5", | ||
| 24414 | + "CARGO_PKG_VERSION_MAJOR": "0", | ||
| 24415 | + "CARGO_PKG_VERSION_MINOR": "2", | ||
| 24416 | + "CARGO_PKG_VERSION_PATCH": "5", | ||
| 24417 | + "CARGO_PKG_VERSION_PRE": "", | ||
| 24418 | + }, | ||
| 24419 | + features = [ | ||
| 24420 | + "mio-1_0", | ||
| 24421 | + "support-v1_0", | ||
| 24422 | + ], | ||
| 24423 | + named_deps = { | ||
| 24424 | + "mio_1_0": ":mio-1", | ||
| 24425 | + }, | ||
| 24426 | + visibility = [], | ||
| 24427 | + deps = [ | ||
| 24428 | + ":libc-0.2", | ||
| 24429 | + ":signal-hook-0.3", | ||
| 24430 | + ], | ||
| 24431 | +) | ||
| 24432 | + | ||
| 24207 | http_archive( | 24433 | http_archive( |
| 24208 | name = "signal-hook-registry-1.4.8.crate", | 24434 | name = "signal-hook-registry-1.4.8.crate", |
| 24209 | sha256 = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b", | 24435 | sha256 = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b", |
modified
third-party/rust/Cargo.lock +36 -0 | @@ -1201,6 +1201,20 @@ version = "0.8.22" | ||
| 1201 | 1201 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 1202 | 1202 | checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" |
| 1203 | 1203 | |
| 1204 | +[[package]] | |
| 1205 | +name = "crossterm" | |
| 1206 | +version = "0.28.1" | |
| 1207 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1208 | +checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" | |
| 1209 | +dependencies = [ | |
| 1210 | + "bitflags 2.13.1", | |
| 1211 | + "mio", | |
| 1212 | + "parking_lot", | |
| 1213 | + "rustix 0.38.44", | |
| 1214 | + "signal-hook", | |
| 1215 | + "signal-hook-mio", | |
| 1216 | +] | |
| 1217 | + | |
| 1204 | 1218 | [[package]] |
| 1205 | 1219 | name = "crunchy" |
| 1206 | 1220 | version = "0.2.4" |
| @@ -3326,6 +3340,7 @@ dependencies = [ | ||
| 3326 | 3340 | "arboard", |
| 3327 | 3341 | "coreaudio-rs", |
| 3328 | 3342 | "cpal", |
| 3343 | + "crossterm", | |
| 3329 | 3344 | "dasp_sample", |
| 3330 | 3345 | "egui", |
| 3331 | 3346 | "egui-winit", |
| @@ -6182,6 +6197,27 @@ version = "2.0.1" | ||
| 6182 | 6197 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 6183 | 6198 | checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" |
| 6184 | 6199 | |
| 6200 | +[[package]] | |
| 6201 | +name = "signal-hook" | |
| 6202 | +version = "0.3.18" | |
| 6203 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 6204 | +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" | |
| 6205 | +dependencies = [ | |
| 6206 | + "libc", | |
| 6207 | + "signal-hook-registry", | |
| 6208 | +] | |
| 6209 | + | |
| 6210 | +[[package]] | |
| 6211 | +name = "signal-hook-mio" | |
| 6212 | +version = "0.2.5" | |
| 6213 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 6214 | +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" | |
| 6215 | +dependencies = [ | |
| 6216 | + "libc", | |
| 6217 | + "mio", | |
| 6218 | + "signal-hook", | |
| 6219 | +] | |
| 6220 | + | |
| 6185 | 6221 | [[package]] |
| 6186 | 6222 | name = "signal-hook-registry" |
| 6187 | 6223 | version = "1.4.8" |
| @@ -1201,6 +1201,20 @@ version = "0.8.22" | |||
| 1201 | source = "registry+https://github.com/rust-lang/crates.io-index" | 1201 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 1202 | checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" | 1202 | checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" |
| 1203 | 1203 | ||
| 1204 | +[[package]] | ||
| 1205 | +name = "crossterm" | ||
| 1206 | +version = "0.28.1" | ||
| 1207 | +source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1208 | +checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" | ||
| 1209 | +dependencies = [ | ||
| 1210 | + "bitflags 2.13.1", | ||
| 1211 | + "mio", | ||
| 1212 | + "parking_lot", | ||
| 1213 | + "rustix 0.38.44", | ||
| 1214 | + "signal-hook", | ||
| 1215 | + "signal-hook-mio", | ||
| 1216 | +] | ||
| 1217 | + | ||
| 1204 | [[package]] | 1218 | [[package]] |
| 1205 | name = "crunchy" | 1219 | name = "crunchy" |
| 1206 | version = "0.2.4" | 1220 | version = "0.2.4" |
| @@ -3326,6 +3340,7 @@ dependencies = [ | |||
| 3326 | "arboard", | 3340 | "arboard", |
| 3327 | "coreaudio-rs", | 3341 | "coreaudio-rs", |
| 3328 | "cpal", | 3342 | "cpal", |
| 3343 | + "crossterm", | ||
| 3329 | "dasp_sample", | 3344 | "dasp_sample", |
| 3330 | "egui", | 3345 | "egui", |
| 3331 | "egui-winit", | 3346 | "egui-winit", |
| @@ -6182,6 +6197,27 @@ version = "2.0.1" | |||
| 6182 | source = "registry+https://github.com/rust-lang/crates.io-index" | 6197 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 6183 | checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" | 6198 | checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" |
| 6184 | 6199 | ||
| 6200 | +[[package]] | ||
| 6201 | +name = "signal-hook" | ||
| 6202 | +version = "0.3.18" | ||
| 6203 | +source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 6204 | +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" | ||
| 6205 | +dependencies = [ | ||
| 6206 | + "libc", | ||
| 6207 | + "signal-hook-registry", | ||
| 6208 | +] | ||
| 6209 | + | ||
| 6210 | +[[package]] | ||
| 6211 | +name = "signal-hook-mio" | ||
| 6212 | +version = "0.2.5" | ||
| 6213 | +source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 6214 | +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" | ||
| 6215 | +dependencies = [ | ||
| 6216 | + "libc", | ||
| 6217 | + "mio", | ||
| 6218 | + "signal-hook", | ||
| 6219 | +] | ||
| 6220 | + | ||
| 6185 | [[package]] | 6221 | [[package]] |
| 6186 | name = "signal-hook-registry" | 6222 | name = "signal-hook-registry" |
| 6187 | version = "1.4.8" | 6223 | version = "1.4.8" |
modified
third-party/rust/Cargo.toml +4 -0 | @@ -34,6 +34,10 @@ arboard = { version = "3", default-features = false, features = ["image-data"] } | ||
| 34 | 34 | cpal = { path = "cpal" } |
| 35 | 35 | # `default_fonts` because a shared library has no host app to install a font set |
| 36 | 36 | # for it. vidya-core turns egui's defaults off; the union is what gets built. |
| 37 | +# The terminal jolt-tui paints into. `events` only: the crate's default set | |
| 38 | +# reaches for a Windows console API and a serde derive, and neither the reader | |
| 39 | +# nor the writer here wants either. | |
| 40 | +crossterm = { version = "0.28", default-features = false, features = ["events"] } | |
| 37 | 41 | egui = { version = "0.31", features = ["default_fonts"] } |
| 38 | 42 | egui-winit = "0.31" |
| 39 | 43 | egui_glow = "0.31" |
| @@ -34,6 +34,10 @@ arboard = { version = "3", default-features = false, features = ["image-data"] } | |||
| 34 | cpal = { path = "cpal" } | 34 | cpal = { path = "cpal" } |
| 35 | # `default_fonts` because a shared library has no host app to install a font set | 35 | # `default_fonts` because a shared library has no host app to install a font set |
| 36 | # for it. vidya-core turns egui's defaults off; the union is what gets built. | 36 | # for it. vidya-core turns egui's defaults off; the union is what gets built. |
| 37 | +# The terminal jolt-tui paints into. `events` only: the crate's default set | ||
| 38 | +# reaches for a Windows console API and a serde derive, and neither the reader | ||
| 39 | +# nor the writer here wants either. | ||
| 40 | +crossterm = { version = "0.28", default-features = false, features = ["events"] } | ||
| 37 | egui = { version = "0.31", features = ["default_fonts"] } | 41 | egui = { version = "0.31", features = ["default_fonts"] } |
| 38 | egui-winit = "0.31" | 42 | egui-winit = "0.31" |
| 39 | egui_glow = "0.31" | 43 | egui_glow = "0.31" |
added
third-party/rust/fixups/signal-hook/fixups.toml +5 -0 | new file mode 100644 | ||
| @@ -0,0 +1,5 @@ | ||
| 1 | +# Nothing to run. signal-hook's build script compiles one C file, and only | |
| 2 | +# under `extended-siginfo-raw`, which nothing in this graph asks for — crossterm | |
| 3 | +# takes signal-hook for SIGWINCH alone. Without this line reindeer warns on | |
| 4 | +# every `just buckify` that it does not know what the script is for. | |
| 5 | +buildscript.run = false | |
| new file mode 100644 | |||
| @@ -0,0 +1,5 @@ | |||
| 1 | +# Nothing to run. signal-hook's build script compiles one C file, and only | ||
| 2 | +# under `extended-siginfo-raw`, which nothing in this graph asks for — crossterm | ||
| 3 | +# takes signal-hook for SIGWINCH alone. Without this line reindeer warns on | ||
| 4 | +# every `just buckify` that it does not know what the script is for. | ||
| 5 | +buildscript.run = false | ||