Skip to main content

max / quasi

Draw a described screen in a terminal quasi-tui, the first non-webview renderer of the screen tree. Both hosts have always been webview hosts, so every finding that shaped this vocabulary came from a renderer that can express anything and therefore never pushed back. This is the consumer that pushes back. Drawing only. A screen in, cells in a ratatui buffer out, no state: flow layout, every node answering a height for a width and then drawing into the rect it was given. Focus order, scroll, field editing and history are the interaction runtime and are not here. Every `Node` member is drawn or declined in writing, and the declines are the point. What it found so far: - A secret field has no described value to draw. `Field::value` drops what it is handed when the kind is `Secret`, deliberately, and a browser owns an input's contents so a webview never noticed. This is the first node whose drawing is not a function of the description alone. - A tabbed list-detail does not say which tab is showing, and a tab has no label. `Slot::id` is an address chosen for fragment targeting, so using it as a heading puts `contacts-detail` on screen. - Nothing says a region's share of the width. The sidebar is 24 columns and the list pane 40% because this renderer picked those numbers. - `Node::Rich` loses its emphasis. The node carries source so each renderer can answer it its own way, and a terminal's own way should be bold rather than dropping it; docengine renders to markup or to text and to nothing in between. - A toast and a banner draw the same. A toast goes away on its own, which is a clock the description does not carry. - `quasi_http::Render` is an HTTP host's contract rather than a renderer's, so this implements nothing shared with quasi-webview. The row run is what made the list drawable without hardcoding a member order: the terminal reads the parts the description says, in the order it says them.
Author: Max Johnson <me@maxj.phd> · 2026-08-12 16:11 UTC
Signed with PGP, not checked
Commit: e150b4e7ad1a0a56e9a21dd94acfa3666d1202ac
Parent: c80e473
8 files changed, +1557 insertions, -12 deletions
M Cargo.lock +283 -12
@@ -32,6 +32,12 @@
32 32 "alloc-no-stdlib",
33 33 ]
34 34
35 + [[package]]
36 + name = "allocator-api2"
37 + version = "0.2.21"
38 + source = "registry+https://github.com/rust-lang/crates.io-index"
39 + checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
40 +
35 41 [[package]]
36 42 name = "ammonia"
37 43 version = "4.1.4"
@@ -330,6 +336,15 @@
330 336 "toml 0.9.12+spec-1.1.0",
331 337 ]
332 338
339 + [[package]]
340 + name = "castaway"
341 + version = "0.2.4"
342 + source = "registry+https://github.com/rust-lang/crates.io-index"
343 + checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a"
344 + dependencies = [
345 + "rustversion",
346 + ]
347 +
333 348 [[package]]
334 349 name = "cc"
335 350 version = "1.4.2"
@@ -395,6 +410,20 @@
395 410 "memchr",
396 411 ]
397 412
413 + [[package]]
414 + name = "compact_str"
415 + version = "0.9.1"
416 + source = "registry+https://github.com/rust-lang/crates.io-index"
417 + checksum = "9dfdd1c2274d9aa354115b09dc9a901d6c5576818cdf70d14cae2bdb47df00ab"
418 + dependencies = [
419 + "castaway",
420 + "cfg-if",
421 + "itoa",
422 + "rustversion",
423 + "ryu",
424 + "static_assertions",
425 + ]
426 +
398 427 [[package]]
399 428 name = "cookie"
400 429 version = "0.18.2"
@@ -544,8 +573,18 @@
544 573 source = "registry+https://github.com/rust-lang/crates.io-index"
545 574 checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d"
546 575 dependencies = [
547 - "darling_core",
548 - "darling_macro",
576 + "darling_core 0.23.0",
577 + "darling_macro 0.23.0",
578 + ]
579 +
580 + [[package]]
581 + name = "darling"
582 + version = "0.24.0"
583 + source = "registry+https://github.com/rust-lang/crates.io-index"
584 + checksum = "88490bf1b990d87eaaa7ac8aa887f629a08e7359765b4911faf63c3763347d23"
585 + dependencies = [
586 + "darling_core 0.24.0",
587 + "darling_macro 0.24.0",
549 588 ]
550 589
551 590 [[package]]
@@ -561,17 +600,41 @@
561 600 "syn 2.0.119",
562 601 ]
563 602
603 + [[package]]
604 + name = "darling_core"
605 + version = "0.24.0"
606 + source = "registry+https://github.com/rust-lang/crates.io-index"
607 + checksum = "084e274f91c482280130e1e34e0b8d6e66776a060d7b6de7b84289ca778868c4"
608 + dependencies = [
609 + "ident_case",
610 + "proc-macro2",
611 + "quote",
612 + "strsim",
613 + "syn 3.0.3",
614 + ]
615 +
564 616 [[package]]
565 617 name = "darling_macro"
566 618 version = "0.23.0"
567 619 source = "registry+https://github.com/rust-lang/crates.io-index"
568 620 checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d"
569 621 dependencies = [
570 - "darling_core",
622 + "darling_core 0.23.0",
571 623 "quote",
572 624 "syn 2.0.119",
573 625 ]
574 626
627 + [[package]]
628 + name = "darling_macro"
629 + version = "0.24.0"
630 + source = "registry+https://github.com/rust-lang/crates.io-index"
631 + checksum = "68f5792fa0d41cd2325ce0ffa64f0a340eaebd4971a3a0c5e1ffd2cc488a355e"
632 + dependencies = [
633 + "darling_core 0.24.0",
634 + "quote",
635 + "syn 3.0.3",
636 + ]
637 +
575 638 [[package]]
576 639 name = "dbus"
577 640 version = "0.9.12"
@@ -796,6 +859,12 @@
796 859 source = "registry+https://github.com/rust-lang/crates.io-index"
797 860 checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
798 861
862 + [[package]]
863 + name = "either"
864 + version = "1.17.0"
865 + source = "registry+https://github.com/rust-lang/crates.io-index"
866 + checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d"
867 +
799 868 [[package]]
800 869 name = "embed-resource"
801 870 version = "3.0.11"
@@ -1317,6 +1386,8 @@
1317 1386 source = "registry+https://github.com/rust-lang/crates.io-index"
1318 1387 checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
1319 1388 dependencies = [
1389 + "allocator-api2",
1390 + "equivalent",
1320 1391 "foldhash",
1321 1392 ]
1322 1393
@@ -1326,6 +1397,8 @@
1326 1397 source = "registry+https://github.com/rust-lang/crates.io-index"
1327 1398 checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
1328 1399 dependencies = [
1400 + "allocator-api2",
1401 + "equivalent",
1329 1402 "foldhash",
1330 1403 ]
1331 1404
@@ -1608,6 +1681,25 @@
1608 1681 "icu_properties",
1609 1682 ]
1610 1683
1684 + [[package]]
1685 + name = "include_dir"
1686 + version = "0.7.4"
1687 + source = "registry+https://github.com/rust-lang/crates.io-index"
1688 + checksum = "923d117408f1e49d914f1a379a309cffe4f18c05cf4e3d12e613a15fc81bd0dd"
1689 + dependencies = [
1690 + "include_dir_macros",
1691 + ]
1692 +
1693 + [[package]]
1694 + name = "include_dir_macros"
1695 + version = "0.7.4"
1696 + source = "registry+https://github.com/rust-lang/crates.io-index"
1697 + checksum = "7cab85a7ed0bd5f0e76d93846e0147172bed2e2d3f859bcc33a8d9699cad1a75"
1698 + dependencies = [
1699 + "proc-macro2",
1700 + "quote",
1701 + ]
1702 +
1611 1703 [[package]]
1612 1704 name = "indexmap"
1613 1705 version = "1.9.3"
@@ -1631,6 +1723,15 @@
1631 1723 "serde_core",
1632 1724 ]
1633 1725
1726 + [[package]]
1727 + name = "indoc"
1728 + version = "2.0.7"
1729 + source = "registry+https://github.com/rust-lang/crates.io-index"
1730 + checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706"
1731 + dependencies = [
1732 + "rustversion",
1733 + ]
1734 +
1634 1735 [[package]]
1635 1736 name = "infer"
1636 1737 version = "0.19.0"
@@ -1640,12 +1741,34 @@
1640 1741 "cfb",
1641 1742 ]
1642 1743
1744 + [[package]]
1745 + name = "instability"
1746 + version = "0.3.13"
1747 + source = "registry+https://github.com/rust-lang/crates.io-index"
1748 + checksum = "2bf84e73fa6f27f299dec58e13223cf70db80da872eb921d4f6138342a0eabc8"
1749 + dependencies = [
1750 + "darling 0.24.0",
1751 + "indoc",
1752 + "proc-macro2",
1753 + "quote",
1754 + "syn 3.0.3",
1755 + ]
1756 +
1643 1757 [[package]]
1644 1758 name = "ipnet"
1645 1759 version = "2.12.1"
1646 1760 source = "registry+https://github.com/rust-lang/crates.io-index"
1647 1761 checksum = "6a756c3fac73139e83f14c2d742155dd2b78d3ee56597b419a0579b7bdd6dd78"
1648 1762
1763 + [[package]]
1764 + name = "itertools"
1765 + version = "0.14.0"
1766 + source = "registry+https://github.com/rust-lang/crates.io-index"
1767 + checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285"
1768 + dependencies = [
1769 + "either",
1770 + ]
1771 +
1649 1772 [[package]]
1650 1773 name = "itoa"
1651 1774 version = "1.0.18"
@@ -1805,6 +1928,16 @@
1805 1928 "serde_json",
1806 1929 ]
1807 1930
1931 + [[package]]
1932 + name = "kasuari"
1933 + version = "0.4.12"
1934 + source = "registry+https://github.com/rust-lang/crates.io-index"
1935 + checksum = "bde5057d6143cc94e861d90f591b9303d6716c6b9602309150bd068853c10899"
1936 + dependencies = [
1937 + "hashbrown 0.16.1",
1938 + "thiserror 2.0.20",
1939 + ]
1940 +
1808 1941 [[package]]
1809 1942 name = "keyboard-types"
1810 1943 version = "0.7.0"
@@ -1885,6 +2018,15 @@
1885 2018 "vcpkg",
1886 2019 ]
1887 2020
2021 + [[package]]
2022 + name = "line-clipping"
2023 + version = "0.3.8"
2024 + source = "registry+https://github.com/rust-lang/crates.io-index"
2025 + checksum = "e752191d037c44ad111a8caa762921926658402f01cc1253f7bef2020ece4f5e"
2026 + dependencies = [
2027 + "bitflags 2.13.1",
2028 + ]
2029 +
1888 2030 [[package]]
1889 2031 name = "litemap"
1890 2032 version = "0.8.2"
@@ -1906,6 +2048,26 @@
1906 2048 source = "registry+https://github.com/rust-lang/crates.io-index"
1907 2049 checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
1908 2050
2051 + [[package]]
2052 + name = "lru"
2053 + version = "0.18.2"
2054 + source = "registry+https://github.com/rust-lang/crates.io-index"
2055 + checksum = "5d2f2f9b4ba7e6b24d95e7e899329d35be83bcded72c8540cdd5368932d1d90a"
2056 + dependencies = [
2057 + "hashbrown 0.17.1",
2058 + ]
2059 +
2060 + [[package]]
2061 + name = "makeover"
2062 + version = "2.5.0"
2063 + source = "registry+https://github.com/rust-lang/crates.io-index"
2064 + checksum = "3ba9cbc9da41bffe0514e6d6744cecf8097eb18676edcaf62a7dd08cea42e4aa"
2065 + dependencies = [
2066 + "include_dir",
2067 + "serde",
2068 + "toml 1.1.4+spec-1.1.0",
2069 + ]
2070 +
1909 2071 [[package]]
1910 2072 name = "makeover-geometry"
1911 2073 version = "0.7.0"
@@ -1928,6 +2090,17 @@
1928 2090 "makeover-layout",
1929 2091 ]
1930 2092
2093 + [[package]]
2094 + name = "makeover-tui"
2095 + version = "0.15.0"
2096 + source = "registry+https://github.com/rust-lang/crates.io-index"
2097 + checksum = "17f9d6a744f90ad780bd7b98d8c4b1bb2c1e16116e66b9b87eaaee085975a7e1"
2098 + dependencies = [
2099 + "makeover",
2100 + "makeover-layout",
2101 + "ratatui",
2102 + ]
2103 +
1931 2104 [[package]]
1932 2105 name = "makeover-webview"
1933 2106 version = "0.32.0"
@@ -2640,6 +2813,18 @@
2640 2813 "tauri",
2641 2814 ]
2642 2815
2816 + [[package]]
2817 + name = "quasi-tui"
2818 + version = "0.1.0"
2819 + dependencies = [
2820 + "docengine",
2821 + "makeover",
2822 + "makeover-layout",
2823 + "makeover-tui",
2824 + "quasi-router",
2825 + "ratatui",
2826 + ]
2827 +
2643 2828 [[package]]
2644 2829 name = "quasi-webview"
2645 2830 version = "0.1.0"
@@ -2681,6 +2866,54 @@
2681 2866 source = "registry+https://github.com/rust-lang/crates.io-index"
2682 2867 checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
2683 2868
2869 + [[package]]
2870 + name = "ratatui"
2871 + version = "0.30.2"
2872 + source = "registry+https://github.com/rust-lang/crates.io-index"
2873 + checksum = "3274ba0a2c5e1bcad2a2005d20f4dc59dad26b2eb0940fb094500dba4099d57d"
2874 + dependencies = [
2875 + "instability",
2876 + "ratatui-core",
2877 + "ratatui-widgets",
2878 + ]
2879 +
2880 + [[package]]
2881 + name = "ratatui-core"
2882 + version = "0.1.2"
2883 + source = "registry+https://github.com/rust-lang/crates.io-index"
2884 + checksum = "cbb175c433c8e28a809d1f5773a2ae96e68c0ce40db865cbab1020bf33ae479c"
2885 + dependencies = [
2886 + "bitflags 2.13.1",
2887 + "compact_str",
2888 + "hashbrown 0.17.1",
2889 + "itertools",
2890 + "kasuari",
2891 + "lru",
2892 + "strum",
2893 + "thiserror 2.0.20",
2894 + "unicode-segmentation",
2895 + "unicode-truncate",
2896 + "unicode-width",
2897 + ]
2898 +
2899 + [[package]]
2900 + name = "ratatui-widgets"
2901 + version = "0.3.2"
2902 + source = "registry+https://github.com/rust-lang/crates.io-index"
2903 + checksum = "66e3d19bcc9130ca376277d93b60767ff121ace3be06f5f95f81dd68956407d1"
2904 + dependencies = [
2905 + "bitflags 2.13.1",
2906 + "hashbrown 0.17.1",
2907 + "indoc",
2908 + "instability",
2909 + "itertools",
2910 + "line-clipping",
2911 + "ratatui-core",
2912 + "strum",
2913 + "unicode-segmentation",
2914 + "unicode-width",
2915 + ]
2916 +
2684 2917 [[package]]
2685 2918 name = "raw-window-handle"
2686 2919 version = "0.6.2"
@@ -3082,7 +3315,7 @@
3082 3315 source = "registry+https://github.com/rust-lang/crates.io-index"
3083 3316 checksum = "8705578779c2b6bd90d84d66eb2e206b708b1a4d7b9f17641b293545bf1c7e46"
3084 3317 dependencies = [
3085 - "darling",
3318 + "darling 0.23.0",
3086 3319 "proc-macro2",
3087 3320 "quote",
3088 3321 "syn 2.0.119",
@@ -3236,6 +3469,12 @@
3236 3469 source = "registry+https://github.com/rust-lang/crates.io-index"
3237 3470 checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
3238 3471
3472 + [[package]]
3473 + name = "static_assertions"
3474 + version = "1.1.0"
3475 + source = "registry+https://github.com/rust-lang/crates.io-index"
3476 + checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
3477 +
3239 3478 [[package]]
3240 3479 name = "string_cache"
3241 3480 version = "0.9.0"
@@ -3266,6 +3505,27 @@
3266 3505 source = "registry+https://github.com/rust-lang/crates.io-index"
3267 3506 checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
3268 3507
3508 + [[package]]
3509 + name = "strum"
3510 + version = "0.28.0"
3511 + source = "registry+https://github.com/rust-lang/crates.io-index"
3512 + checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd"
3513 + dependencies = [
3514 + "strum_macros",
3515 + ]
3516 +
3517 + [[package]]
3518 + name = "strum_macros"
3519 + version = "0.28.0"
3520 + source = "registry+https://github.com/rust-lang/crates.io-index"
3521 + checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664"
3522 + dependencies = [
3523 + "heck 0.5.0",
3524 + "proc-macro2",
3525 + "quote",
3526 + "syn 2.0.119",
3527 + ]
3528 +
3269 3529 [[package]]
3270 3530 name = "swift-rs"
3271 3531 version = "1.0.7"
@@ -4052,6 +4312,17 @@
4052 4312 source = "registry+https://github.com/rust-lang/crates.io-index"
4053 4313 checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
4054 4314
4315 + [[package]]
4316 + name = "unicode-truncate"
4317 + version = "2.0.1"
4318 + source = "registry+https://github.com/rust-lang/crates.io-index"
4319 + checksum = "16b380a1238663e5f8a691f9039c73e1cdae598a30e9855f541d29b08b53e9a5"
4320 + dependencies = [
4321 + "itertools",
4322 + "unicode-segmentation",
4323 + "unicode-width",
4324 + ]
4325 +
4055 4326 [[package]]
4056 4327 name = "unicode-width"
4057 4328 version = "0.2.2"
@@ -4888,14 +5159,6 @@
4888 5159 source = "registry+https://github.com/rust-lang/crates.io-index"
4889 5160 checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
4890 5161
4891 - [[patch.unused]]
4892 - name = "synckit-client"
4893 - version = "0.8.0"
4894 -
4895 - [[patch.unused]]
4896 - name = "synckit-config"
4897 - version = "0.2.0"
4898 -
4899 5162 [[patch.unused]]
4900 5163 name = "kberg"
4901 5164 version = "0.1.0"
@@ -4907,3 +5170,11 @@
4907 5170 [[patch.unused]]
4908 5171 name = "tagtree"
4909 5172 version = "0.4.0"
5173 +
5174 + [[patch.unused]]
5175 + name = "synckit-client"
5176 + version = "0.8.0"
5177 +
5178 + [[patch.unused]]
5179 + name = "synckit-config"
5180 + version = "0.2.0"
M Cargo.toml +1
@@ -7,6 +7,7 @@
7 7 "crates/quasi-router",
8 8 "crates/quasi-store",
9 9 "crates/quasi-tauri",
10 + "crates/quasi-tui",
10 11 "crates/quasi-webview",
11 12 ]
12 13 # The scaffolder's template is real Rust and real manifests rather than liquid,
@@ -1,0 +1,34 @@
1 + [package]
2 + name = "quasi-tui"
3 + version = "0.1.0"
4 + description = "The terminal renderer for quasi: a screen description in, cells in a ratatui buffer out"
5 + edition.workspace = true
6 + rust-version.workspace = true
7 + authors.workspace = true
8 + repository.workspace = true
9 + license.workspace = true
10 + publish = false
11 +
12 + [lints]
13 + workspace = true
14 +
15 + [dependencies]
16 + quasi-router = { path = "../quasi-router", version = "0.1.0" }
17 + makeover-layout = "0.16.0"
18 + # The depth palette, the theme bridge and the table. Everything else this crate
19 + # draws is written here first and lifted upstream once a second consumer wants
20 + # it, which is the order the suite has always moved in: the constrained consumer
21 + # finds the shape, and the shared crate takes it after it is known rather than
22 + # before.
23 + makeover-tui = { version = "0.15.0", features = ["theme"] }
24 + ratatui = { version = "0.30", default-features = false }
25 + # `Node::Rich` carries markdown source. A terminal has no markup to hand it to,
26 + # so it takes the text: `render_plain` is docengine's own answer for exactly
27 + # this, and it is the third preset the same node already has two of.
28 + docengine = { git = "https://makenot.work/git/max/docengine.git" }
29 +
30 + [dev-dependencies]
31 + # Only the tests need this, and only to load a bundled theme file:
32 + # `makeover_tui::Theme` is `#[non_exhaustive]`, so `Theme::from_theme` is the
33 + # one way to get one and a test cannot hand-build a partial.
34 + makeover = "2.5"
@@ -1,0 +1,177 @@
1 + //! The terminal renderer for quasi.
2 + //!
3 + //! <!-- wiki: quasi-overview -->
4 + //!
5 + //! # Why this exists
6 + //!
7 + //! `quasi-router`'s own diagram says `renderer: webview | tui | egui`, and until
8 + //! this crate two of those three did not exist. `quasi-webview` was the only
9 + //! renderer of the screen tree, and both hosts are webview hosts: `quasi-axum`
10 + //! serves the markup over HTTP, `quasi-tauri` serves the same markup over a
11 + //! custom protocol. So every finding that has ever shaped the vocabulary came
12 + //! from a webview port.
13 + //!
14 + //! That is the failure `makeover-layout`'s own header names. A webview can
15 + //! express anything, so it never pushes back, and a vocabulary derived from the
16 + //! renderer that can express everything comes out CSS-shaped with adapters
17 + //! bolted onto the constrained renderers afterwards. The counter-principle is
18 + //! to let the constrained consumer set the vocabulary, and quasi was the one
19 + //! place it was not being applied.
20 + //!
21 + //! This crate is the constrained consumer. What it cannot draw is the point of
22 + //! it: every place a description says something a terminal has no way to honour
23 + //! is a finding, and the findings are the deliverable.
24 + //!
25 + //! # What it is not
26 + //!
27 + //! Not an implementation of `quasi_http::Render`. That trait answers a `String`
28 + //! and a content type, which is an HTTP host's contract rather than a
29 + //! renderer's; a terminal answers cells in a buffer. The two share the
30 + //! description and nothing else, which is worth knowing before reaching for the
31 + //! trait's name.
32 + //!
33 + //! Not an event loop either, yet. This half is drawing: a screen in, a buffer
34 + //! out, no state. Focus order, scroll position, field editing and history are
35 + //! the interaction runtime, and they are the half that is real work.
36 + //!
37 + //! # The shape
38 + //!
39 + //! Flow layout, top to bottom. Every node answers a height for a width and then
40 + //! draws into the rect it was given, which is the smallest thing that composes
41 + //! and is what a description with no geometry in it can support. Nothing here
42 + //! measures twice.
43 +
44 + mod node;
45 + mod region;
46 + mod text;
47 +
48 + #[cfg(test)]
49 + mod tests;
50 +
51 + use makeover_layout as layout;
52 + use makeover_tui::table::TableStyle;
53 + use makeover_tui::{Fidelity, Palette, Theme};
54 + use quasi_router::{Node, Screen};
55 + use ratatui::buffer::Buffer;
56 + use ratatui::layout::Rect;
57 + use ratatui::style::{Modifier, Style};
58 +
59 + /// A terminal renderer for a described screen.
60 + ///
61 + /// Holds what a drawing needs and no screen state: the theme's colours, the
62 + /// terminal's colour fidelity, and the table style derived from both. A host
63 + /// makes one and keeps it.
64 + #[derive(Debug, Clone)]
65 + pub struct Tui {
66 + theme: Theme,
67 + palette: Palette,
68 + table: TableStyle,
69 + }
70 +
71 + impl Tui {
72 + /// A renderer drawing in this theme, at this terminal's fidelity.
73 + #[must_use]
74 + pub fn new(theme: Theme, fidelity: Fidelity) -> Self {
75 + let theme = theme.for_terminal(fidelity);
76 + Self {
77 + palette: theme.palette(fidelity),
78 + table: TableStyle::from_theme(&theme),
79 + theme,
80 + }
81 + }
82 +
83 + /// The colours this renderer draws in.
84 + #[must_use]
85 + pub const fn theme(&self) -> &Theme {
86 + &self.theme
87 + }
88 +
89 + /// The depth palette, for a host painting its own chrome around a screen.
90 + #[must_use]
91 + pub const fn palette(&self) -> &Palette {
92 + &self.palette
93 + }
94 +
95 + /// Draw a whole screen into `area`.
96 + ///
97 + /// The title is not drawn. A window title is the host's to set, the same
98 + /// way a webview host puts it in `<title>` rather than in the document, and
99 + /// a terminal that painted it would be spending a row on something the
100 + /// terminal emulator already has a place for.
101 + ///
102 + /// [`Screen::discovery`] is declined outright: og:type, an indexability
103 + /// flag and a canonical URL are facts about being crawled, and nothing
104 + /// crawls a terminal.
105 + pub fn screen(&self, screen: &Screen, area: Rect, buf: &mut Buffer) {
106 + let mut rest = area;
107 +
108 + // Notices first and at the top, because a notice belongs to the screen
109 + // rather than to a place in it. A webview leaves where they land to the
110 + // stylesheet; a terminal has no stylesheet, so this is the renderer
111 + // deciding, and the top of the screen is the one place a message about
112 + // the whole screen can go without claiming a region.
113 + for notice in &screen.notices {
114 + let used = self.node(notice, rest, buf);
115 + rest = below(rest, used);
116 + }
117 +
118 + region::screen_regions(self, screen, rest, buf);
119 + }
120 +
121 + /// Draw one node into `area`, and answer the rows it used.
122 + ///
123 + /// Never draws outside `area` and never below it: a node handed less room
124 + /// than it wants is cut off at the bottom, which is what a terminal does
125 + /// with everything. Scrolling is the runtime's, not the drawing's.
126 + pub fn node(&self, node: &Node, area: Rect, buf: &mut Buffer) -> u16 {
127 + node::draw(self, node, area, buf)
128 + }
129 +
130 + /// The rows `node` wants at `width`.
131 + #[must_use]
132 + pub fn height(&self, node: &Node, width: u16) -> u16 {
133 + node::height(self, node, width)
134 + }
135 +
136 + /// The style a tone reads as.
137 + fn tone(&self, tone: layout::Tone) -> Style {
138 + let colour = match tone {
139 + layout::Tone::Neutral => self.theme.content_primary,
140 + layout::Tone::Info => self.theme.status_info,
141 + layout::Tone::Success => self.theme.status_success,
142 + layout::Tone::Warning => self.theme.status_warning,
143 + layout::Tone::Danger => self.theme.status_danger,
144 + };
145 + Style::default().fg(colour)
146 + }
147 +
148 + /// The style a heading reads as.
149 + ///
150 + /// Three depths and two of them are bold, which is the whole of what a
151 + /// terminal has: there is no type scale in a grid of one cell size. A page
152 + /// title takes bold and the accent, a section bold, a subsection the
153 + /// secondary colour. That is the emphasis order the webview's type scale
154 + /// says with size, said with the two axes a cell has.
155 + fn heading(&self, level: layout::Heading) -> Style {
156 + match level {
157 + layout::Heading::Page => Style::default()
158 + .fg(self.theme.action_primary)
159 + .add_modifier(Modifier::BOLD),
160 + layout::Heading::Section => Style::default()
161 + .fg(self.theme.content_primary)
162 + .add_modifier(Modifier::BOLD),
163 + layout::Heading::Subsection => Style::default().fg(self.theme.content_secondary),
164 + }
165 + }
166 + }
167 +
168 + /// What is left of `area` after `used` rows from the top.
169 + fn below(area: Rect, used: u16) -> Rect {
170 + let used = used.min(area.height);
171 + Rect {
172 + x: area.x,
173 + y: area.y + used,
174 + width: area.width,
175 + height: area.height - used,
176 + }
177 + }
@@ -1,0 +1,665 @@
1 + //! One node into cells.
2 + //!
3 + //! Every member of [`Node`] is drawn here or declined here in writing, which is
4 + //! what `179b088d` asks for. A decline is a comment saying what a terminal has
5 + //! no way to honour, and each one is a finding rather than an omission.
6 +
7 + use makeover_layout as layout;
8 + use makeover_tui::table;
9 + use quasi_router::{Act, Cell, Cells, Field, Figure, Meter, Node, Part, Row, Tag};
10 + use ratatui::buffer::Buffer;
11 + use ratatui::layout::Rect;
12 + use ratatui::style::{Modifier, Style};
13 + use ratatui::text::{Line, Span};
14 +
15 + use crate::{Tui, below, text};
16 +
17 + /// The rows `node` wants at `width`.
18 + pub(crate) fn height(tui: &Tui, node: &Node, width: u16) -> u16 {
19 + match node {
20 + Node::Heading { text: content, .. } | Node::Text { text: content, .. } => {
21 + text::height(content, width)
22 + }
23 + Node::Rich { source } => text::height(&docengine::render_plain(source), width),
24 + Node::Act(act) => text::line_height(&act_line(tui, act), width),
25 + Node::Link { text: label, .. } => text::height(label, width),
26 + Node::Token(tag) => text::line_height(&Line::from(tag_span(tui, tag)), width),
27 + Node::Figure(figure) => figure_height(tui, figure, width),
28 + Node::Notice { text: content, .. } => text::height(content, width),
29 + Node::StandIn { message, act, .. } => {
30 + text::height(message, width) + act.as_ref().map_or(0, |_| 1)
31 + }
32 + Node::Field(field) => field_height(tui, field, width),
33 + Node::Form { fields, .. } => {
34 + fields
35 + .iter()
36 + .map(|field| field_height(tui, field, width))
37 + .sum::<u16>()
38 + // The submit button, on its own row under the last question.
39 + + 1
40 + }
41 + Node::List { rows, more } => {
42 + let gutter = list_gutter(rows);
43 + rows.iter()
44 + .map(|row| {
45 + text::line_height(&row_line(tui, row), width.saturating_sub(gutter)).max(1)
46 + })
47 + .sum::<u16>()
48 + + u16::from(more.is_some())
49 + }
50 + Node::Table { columns, rows } => table_height(columns, rows),
51 + Node::Select { options, .. } => {
52 + text::line_height(&Line::from(select_spans(tui, options, None)), width)
53 + }
54 + Node::Meter(meter) => text::line_height(&meter_line(tui, meter), width),
55 + Node::Stats { figures } => figures
56 + .iter()
57 + .map(|(figure, _)| figure_height(tui, figure, width))
58 + .sum(),
59 + Node::Region(slot) => crate::region::height(tui, slot, width),
60 + }
61 + }
62 +
63 + /// Draw `node` at the top of `area`, and answer the rows it used.
64 + pub(crate) fn draw(tui: &Tui, node: &Node, area: Rect, buf: &mut Buffer) -> u16 {
65 + if area.width == 0 || area.height == 0 {
66 + return 0;
67 + }
68 +
69 + match node {
70 + Node::Heading { level, text: title } => text::draw(title, tui.heading(*level), area, buf),
71 +
72 + Node::Text {
73 + text: content,
74 + tone,
75 + } => text::draw(content, tui.tone(*tone), area, buf),
76 +
77 + // Markdown source, and a terminal has no markup to hand it to. It takes
78 + // the text, which is docengine's own answer for this and the third
79 + // preset the same node already has two of. What is lost is the
80 + // emphasis: a webview draws `**ship it**` bold and this draws the words.
81 + // Bold is a thing a cell can be, so this is a gap in the shared
82 + // markdown crate rather than a limit of the terminal, and it is filed.
83 + Node::Rich { source } => text::draw(
84 + &docengine::render_plain(source),
85 + Style::default().fg(tui.theme().content_primary),
86 + area,
87 + buf,
88 + ),
89 +
90 + Node::Act(act) => text::draw_line(&act_line(tui, act), area, buf),
91 +
92 + // A link is text and an address, and a terminal cannot put the address
93 + // under the words the way an anchor does. Underlined, which is the one
94 + // affordance a cell has that says "this goes somewhere", and the
95 + // address is the runtime's to follow when the link has focus.
96 + Node::Link { text: label, .. } => text::draw(
97 + label,
98 + Style::default()
99 + .fg(tui.theme().action_primary)
100 + .add_modifier(Modifier::UNDERLINED),
101 + area,
102 + buf,
103 + ),
104 +
105 + Node::Token(tag) => text::draw_line(&Line::from(tag_span(tui, tag)), area, buf),
106 +
107 + Node::Figure(figure) => draw_figure(tui, figure, area, buf),
108 +
109 + // A banner and a toast are the same rows here. A toast is a message
110 + // that goes away on its own, which is a clock the description does not
111 + // carry and the drawing has no way to keep, so the kind is read and
112 + // deliberately not honoured. Filed.
113 + Node::Notice {
114 + tone,
115 + text: content,
116 + ..
117 + } => {
118 + let style = tui.tone(*tone).add_modifier(Modifier::BOLD);
119 + text::draw(content, style, area, buf)
120 + }
121 +
122 + Node::StandIn {
123 + state,
124 + message,
125 + act,
126 + } => {
127 + let style = match state {
128 + layout::Readiness::Failed => tui.tone(layout::Tone::Danger),
129 + _ => Style::default().fg(tui.theme().content_muted),
130 + };
131 + let used = text::draw(message, style, area, buf);
132 + match act {
133 + Some(act) => used + text::draw_line(&act_line(tui, act), below(area, used), buf),
134 + None => used,
135 + }
136 + }
137 +
138 + Node::Field(field) => draw_field(tui, field, area, buf),
139 +
140 + Node::Form { submit, fields, .. } => {
141 + let mut used = 0;
142 + for field in fields {
143 + used += draw_field(tui, field, below(area, used), buf);
144 + }
145 + // The submit, drawn as the act it is. The form's own action is not
146 + // drawn: an address is not a thing a cell can show, and the runtime
147 + // is what follows it.
148 + used + text::draw_line(
149 + &Line::from(vec![Span::styled(
150 + format!("[ {submit} ]"),
151 + Style::default()
152 + .fg(tui.theme().selection_on)
153 + .bg(tui.theme().action_primary),
154 + )]),
155 + below(area, used),
156 + buf,
157 + )
158 + }
159 +
160 + Node::List { rows, more } => {
161 + // A gutter for the tick and the current marker, and only when some
162 + // row in the list has one. Both are facts about the row that a
163 + // webview says with a checkbox and an `aria-current`, and neither
164 + // is content, so neither belongs in the run. A list where no row is
165 + // tickable spends no columns on the possibility.
166 + let gutter = list_gutter(rows);
167 + let mut used = 0;
168 + for row in rows {
169 + let line = row_line(tui, row);
170 + let at = below(area, used);
171 + if at.height == 0 {
172 + break;
173 + }
174 + draw_gutter(tui, row, at, buf);
175 + let body = Rect {
176 + x: at.x + gutter,
177 + width: at.width.saturating_sub(gutter),
178 + ..at
179 + };
180 + used += text::draw_line(&line, body, buf).max(1);
181 + }
182 + match more {
183 + Some(rest) => {
184 + let label = rest.remaining.map_or_else(
185 + || "More".to_string(),
186 + |remaining| format!("{remaining} more"),
187 + );
188 + used + text::draw(
189 + &label,
190 + Style::default().fg(tui.theme().action_primary),
191 + below(area, used),
192 + buf,
193 + )
194 + }
195 + None => used,
196 + }
197 + }
198 +
199 + Node::Table { columns, rows } => draw_table(tui, columns, rows, area, buf),
200 +
201 + Node::Select {
202 + options, chosen, ..
203 + } => {
204 + // Segmented, toggle and tabs draw the same here: a row of labels
205 + // with the chosen one lit. The three differ in how much room they
206 + // claim and how they are grouped, which is a geometry question, and
207 + // a terminal has one cell size and no groups.
208 + text::draw_line(
209 + &Line::from(select_spans(tui, options, chosen.as_deref())),
210 + area,
211 + buf,
212 + )
213 + }
214 +
215 + Node::Meter(meter) => text::draw_line(&meter_line(tui, meter), area, buf),
216 +
217 + Node::Stats { figures } => {
218 + // Down and not across. A strip of tiles is a row on a webview
219 + // because a webview has room to the right; a terminal that put four
220 + // figures on one line would have five cells for each caption.
221 + // Stacking is the renderer deciding, and the node still says "these
222 + // belong together", which is what it is for.
223 + let mut used = 0;
224 + for (figure, _) in figures {
225 + used += draw_figure(tui, figure, below(area, used), buf);
226 + }
227 + used
228 + }
229 +
230 + Node::Region(slot) => crate::region::draw(tui, slot, area, buf),
231 + }
232 + }
233 +
234 + /// The columns a list spends before its rows.
235 + ///
236 + /// Four for a tick, because `[x] ` is four cells; two for the current marker
237 + /// alone; none when the list says neither.
238 + fn list_gutter(rows: &[Row]) -> u16 {
239 + if rows.iter().any(|row| row.selected.is_some()) {
240 + 4
241 + } else if rows.iter().any(|row| row.current) {
242 + 2
243 + } else {
244 + 0
245 + }
246 + }
247 +
248 + /// The tick and the current marker, in the columns before a row.
249 + fn draw_gutter(tui: &Tui, row: &Row, area: Rect, buf: &mut Buffer) {
250 + let tick = match row.selected {
251 + Some(true) => "[x]",
252 + Some(false) => "[ ]",
253 + None => "",
254 + };
255 + if !tick.is_empty() {
256 + buf.set_stringn(
257 + area.x,
258 + area.y,
259 + tick,
260 + 3,
261 + Style::default().fg(tui.theme().content_secondary),
262 + );
263 + return;
264 + }
265 + if row.current {
266 + buf.set_stringn(
267 + area.x,
268 + area.y,
269 + ">",
270 + 1,
271 + Style::default().fg(tui.theme().action_primary),
272 + );
273 + }
274 + }
275 +
276 + /// A row's run as one line of spans.
277 + ///
278 + /// The run is what made this possible to write at all. Under the old members a
279 + /// terminal had to know the fixed sequence -- primary, secondary, meta, bar,
280 + /// tokens, actions -- and hardcode it; here it reads what the description says,
281 + /// in the order it says it, and the role picks the style.
282 + fn row_line(tui: &Tui, row: &Row) -> Line<'static> {
283 + let mut spans = Vec::new();
284 + for Part { role, node } in &row.parts {
285 + if !spans.is_empty() {
286 + spans.push(Span::raw(" "));
287 + }
288 + spans.extend(inline_spans(tui, node, part_style(tui, *role)));
289 + }
290 + // `Row::menu` is not drawn, and that is the description's own instruction:
291 + // a menu is reached by right-click on a pointer host, long-press on a touch
292 + // one, and a key in a terminal. The key is the runtime's.
293 + Line::from(spans)
294 + }
295 +
296 + /// The style a row part takes.
297 + fn part_style(tui: &Tui, role: layout::RowPart) -> Style {
298 + let theme = tui.theme();
299 + match role {
300 + layout::RowPart::Primary => Style::default().fg(theme.content_primary),
301 + layout::RowPart::Secondary => Style::default().fg(theme.content_secondary),
302 + layout::RowPart::Meta => Style::default().fg(theme.content_muted),
303 + // Tokens, actions and a proportion each carry their own tone, so the
304 + // part inherits rather than tinting what sits on it. That is exactly
305 + // what `RowPart::intent` answers for a webview, said in colours.
306 + _ => Style::default().fg(theme.content_primary),
307 + }
308 + }
309 +
310 + /// One leaf of a run as spans, under the run's own style.
311 + fn inline_spans(tui: &Tui, node: &Node, inherited: Style) -> Vec<Span<'static>> {
312 + match node {
313 + Node::Text { text, tone } => {
314 + let style = match tone {
315 + layout::Tone::Neutral => inherited,
316 + other => tui.tone(*other),
317 + };
318 + vec![Span::styled(text.clone(), style)]
319 + }
320 + Node::Rich { source } => vec![Span::styled(docengine::render_plain(source), inherited)],
321 + Node::Token(tag) => vec![tag_span(tui, tag)],
322 + Node::Act(act) => act_line(tui, act).spans,
323 + Node::Link { text, .. } => vec![Span::styled(
324 + text.clone(),
325 + Style::default()
326 + .fg(tui.theme().action_primary)
327 + .add_modifier(Modifier::UNDERLINED),
328 + )],
329 + Node::Meter(meter) => meter_line(tui, meter).spans,
330 + Node::Figure(figure) => vec![Span::styled(
331 + format!("{} {}", figure.value, figure.caption),
332 + inherited,
333 + )],
334 + // Everything else is a block, and the containment bound is what
335 + // guarantees one cannot be here. Drawing the text is the honest answer
336 + // to a case the type system says is unreachable.
337 + other => vec![Span::styled(
338 + format!("{other:?}"),
339 + Style::default().fg(tui.theme().status_danger),
340 + )],
341 + }
342 + }
343 +
344 + /// A tag as one span.
345 + fn tag_span(tui: &Tui, tag: &Tag) -> Span<'static> {
346 + let style = tui.tone(tag.tone);
347 + let style = if tag.latched {
348 + style.add_modifier(Modifier::REVERSED)
349 + } else {
350 + style
351 + };
352 + // A chip's removable half is not drawn. The `x` a webview hangs on a chip
353 + // is a second control inside one span, and a terminal reaches a control by
354 + // focusing it: two targets in one cell run is a runtime question, not a
355 + // drawing one, and it is filed rather than guessed at.
356 + // Round for a badge, square for a chip: a chip answers a click and a badge
357 + // does not, and the bracket is the only affordance a cell has left once
358 + // colour is spent on the tone.
359 + match tag.kind {
360 + layout::Token::Badge => Span::styled(format!("({})", tag.label), style),
361 + layout::Token::Chip { .. } => Span::styled(format!("[{}]", tag.label), style),
362 + }
363 + }
364 +
365 + /// A control as a line.
366 + fn act_line(tui: &Tui, act: &Act) -> Line<'static> {
367 + let disabled = act.state.is_some_and(layout::State::suppresses_interaction);
368 + let style = if disabled {
369 + Style::default().fg(tui.theme().content_muted)
370 + } else {
371 + tui.tone(act.tone)
372 + };
373 +
374 + // The key is the one place the description already anticipated a terminal,
375 + // and this is the renderer that finally reads it.
376 + let label = match &act.key {
377 + Some(key) => format!("< {} > ({key})", act.label),
378 + None => format!("< {} >", act.label),
379 + };
380 + // `Act::confirm` is not drawn. It is a question asked after the control is
381 + // pressed, so it belongs to the runtime, and a label saying it would ask
382 + // before there was anything to answer.
383 + Line::from(Span::styled(label, style))
384 + }
385 +
386 + /// A meter as a line, bar and label.
387 + fn meter_line(tui: &Tui, meter: &Meter) -> Line<'static> {
388 + const CELLS: u32 = 10;
389 + let filled = meter
390 + .done
391 + .checked_mul(CELLS)
392 + .and_then(|reached| reached.checked_div(meter.total))
393 + .unwrap_or(0)
394 + .min(CELLS);
395 + let bar = format!(
396 + "{}{}",
397 + "#".repeat(filled as usize),
398 + "-".repeat((CELLS - filled) as usize)
399 + );
400 + let mut spans = vec![Span::styled(bar, tui.tone(meter.tone))];
401 + let reading = match &meter.label {
402 + Some(label) => format!(" {}/{} {label}", meter.done, meter.total),
403 + None => format!(" {}/{}", meter.done, meter.total),
404 + };
405 + spans.push(Span::styled(
406 + reading,
407 + Style::default().fg(tui.theme().content_muted),
408 + ));
409 + Line::from(spans)
410 + }
411 +
412 + /// A figure takes two rows: the number, then what it counts.
413 + fn figure_height(_tui: &Tui, figure: &Figure, width: u16) -> u16 {
414 + text::height(&figure.value, width) + text::height(&figure.caption, width)
415 + }
416 +
417 + fn draw_figure(tui: &Tui, figure: &Figure, area: Rect, buf: &mut Buffer) -> u16 {
418 + let value = match &figure.change {
419 + Some(change) => format!("{} {change}", figure.value),
420 + None => figure.value.clone(),
421 + };
422 + let used = text::draw(
423 + &value,
424 + tui.tone(figure.tone).add_modifier(Modifier::BOLD),
425 + area,
426 + buf,
427 + );
428 + used + text::draw(
429 + &figure.caption,
430 + Style::default().fg(tui.theme().content_muted),
431 + below(area, used),
432 + buf,
433 + )
434 + }
435 +
436 + /// A question takes its label row, its value row, and a row for whatever went
437 + /// wrong.
438 + fn field_height(_tui: &Tui, field: &Field, width: u16) -> u16 {
439 + let label = text::height(&field.label, width);
440 + let body = match field.kind {
441 + layout::FieldKind::Hidden => return 0,
442 + layout::FieldKind::Textarea => 3,
443 + layout::FieldKind::Select | layout::FieldKind::Radio => {
444 + u16::try_from(field.options.len()).unwrap_or(u16::MAX)
445 + }
446 + _ => 1,
447 + };
448 + let note = field
449 + .error
450 + .as_ref()
451 + .or(field.hint.as_ref())
452 + .map_or(0, |note| text::height(note, width));
453 + label + body + note
454 + }
455 +
456 + fn draw_field(tui: &Tui, field: &Field, area: Rect, buf: &mut Buffer) -> u16 {
457 + // A hidden field is data travelling with the form, so there is nothing to
458 + // draw and the runtime submits it. The one field kind a terminal and a
459 + // webview agree on completely.
460 + if matches!(field.kind, layout::FieldKind::Hidden) {
461 + return 0;
462 + }
463 +
464 + let label = if field.required {
465 + format!("{} *", field.label)
466 + } else {
467 + field.label.clone()
468 + };
469 + let mut used = text::draw(
470 + &label,
471 + Style::default().fg(tui.theme().content_secondary),
472 + area,
473 + buf,
474 + );
475 +
476 + let value = field.value.clone().unwrap_or_default();
477 + let placeholder = field.placeholder.clone().unwrap_or_default();
478 + let well = Style::default().fg(tui.theme().content_primary);
479 + let muted = Style::default().fg(tui.theme().content_muted);
480 +
481 + used += match field.kind {
482 + layout::FieldKind::Checkbox => {
483 + let ticked = field.value.as_deref() == Some(Node::SELECTED);
484 + text::draw(
485 + if ticked { "[x]" } else { "[ ]" },
486 + well,
487 + below(area, used),
488 + buf,
489 + )
490 + }
491 + layout::FieldKind::Select | layout::FieldKind::Radio => {
492 + let mut rows = 0;
493 + for choice in &field.options {
494 + let chosen = field.value.as_deref() == Some(choice.value.as_str());
495 + let mark = if chosen { "(*)" } else { "( )" };
496 + rows += text::draw(
497 + &format!("{mark} {}", choice.label),
498 + if chosen { well } else { muted },
499 + below(area, used + rows),
500 + buf,
Lines truncated
@@ -1,0 +1,202 @@
1 + //! Regions into rects.
2 + //!
3 + //! The half of the drawing a webview never has to do. A stylesheet turns
4 + //! `list-detail` into two columns and the browser does the arithmetic; here the
5 + //! arithmetic is the renderer's, and every place the description does not say
6 + //! enough to do it is a finding.
7 + //!
8 + //! Three of them, and all three are recorded on `179b088d`:
9 + //!
10 + //! - **A tabbed arrangement does not say which tab is showing.** `Arrangement::
11 + //! ListDetail { tabbed: true }` says the two regions share the space and only
12 + //! one is visible; nothing says which. A webview never asked, because a
13 + //! stylesheet with `:target` or a class answers it. This draws the first,
14 + //! which is a guess.
15 + //! - **A tab has no label.** [`Slot::id`] is an address, chosen to be stable
16 + //! for fragment targeting, and using it as a heading puts `contacts-detail`
17 + //! on screen.
18 + //! - **Nothing says a region's share.** A sidebar is 24 columns here and a list
19 + //! pane 40% because this renderer picked those numbers. `makeover-geometry`
20 + //! has size classes and the description reaches none of them.
21 +
22 + use makeover_layout as layout;
23 + use makeover_tui::frame;
24 + use quasi_router::{RegionKind, Screen, Slot};
25 + use ratatui::buffer::Buffer;
26 + use ratatui::layout::Rect;
27 + use ratatui::style::{Modifier, Style};
28 +
29 + use crate::{Tui, below, text};
30 +
31 + /// How wide a sidebar is, in columns.
32 + ///
33 + /// A number this renderer chose. See the module header.
34 + const SIDEBAR: u16 = 24;
35 +
36 + /// What share of the width a list pane takes in a list-detail arrangement.
37 + const LIST_SHARE: u16 = 40;
38 +
39 + /// Lay a screen's regions out and draw them.
40 + pub(crate) fn screen_regions(tui: &Tui, screen: &Screen, area: Rect, buf: &mut Buffer) {
41 + let mut rest = area;
42 +
43 + // Bands stack at the top, full width, in the order they were said. A band
44 + // is an arrangement rather than a type -- a page header, a toolbar -- so it
45 + // takes the rows it needs and gets out of the way.
46 + for slot in screen
47 + .slots
48 + .iter()
49 + .filter(|slot| matches!(slot.kind, RegionKind::Band))
50 + {
51 + let used = draw(tui, slot, rest, buf);
52 + rest = below(rest, used);
53 + }
54 +
55 + let body: Vec<&Slot> = screen
56 + .slots
57 + .iter()
58 + .filter(|slot| !matches!(slot.kind, RegionKind::Band | RegionKind::Modal))
59 + .collect();
60 +
61 + match screen.arrangement {
62 + layout::Arrangement::SidebarContent => {
63 + let (left, right) = split(rest, SIDEBAR);
64 + let mut sidebars = 0;
65 + let mut content = right;
66 + for slot in &body {
67 + if matches!(slot.kind, RegionKind::Sidebar) {
68 + let used = draw(tui, slot, below(left, sidebars), buf);
69 + sidebars += used;
70 + } else {
71 + let used = draw(tui, slot, content, buf);
72 + content = below(content, used);
73 + }
74 + }
75 + }
76 + layout::Arrangement::ListDetail { tabbed } => {
77 + if tabbed {
78 + // One at a time, and nothing says which. See the module header.
79 + if let Some(first) = body.first() {
80 + draw(tui, first, rest, buf);
81 + }
82 + } else {
83 + let (left, right) = split(rest, rest.width * LIST_SHARE / 100);
84 + let mut detail = right;
85 + for (index, slot) in body.iter().enumerate() {
86 + if index == 0 {
87 + draw(tui, slot, left, buf);
88 + } else {
89 + let used = draw(tui, slot, detail, buf);
90 + detail = below(detail, used);
91 + }
92 + }
93 + }
94 + }
95 + }
96 +
97 + // Modals last and over everything, which is what a modal is. Centred in
98 + // half the width, because `Depth::Overlay` says it sits above the page and
99 + // says nothing about how much of it to cover.
100 + for slot in screen
101 + .slots
102 + .iter()
103 + .filter(|slot| matches!(slot.kind, RegionKind::Modal))
104 + {
105 + draw(tui, slot, centred(area), buf);
106 + }
107 + }
108 +
109 + /// The rows a region wants at `width`.
110 + pub(crate) fn height(tui: &Tui, slot: &Slot, width: u16) -> u16 {
111 + let inner = width.saturating_sub(2);
112 + let body: u16 = slot
113 + .body
114 + .iter()
115 + .map(|node| crate::node::height(tui, node, inner))
116 + .sum();
117 + // Two rows for the frame, when the region has one.
118 + body + if framed(slot.kind.depth()) { 2 } else { 0 }
119 + }
120 +
121 + /// Draw one region, and answer the rows it used.
122 + pub(crate) fn draw(tui: &Tui, slot: &Slot, area: Rect, buf: &mut Buffer) -> u16 {
123 + if area.width == 0 || area.height == 0 {
124 + return 0;
125 + }
126 +
127 + // The frame, from the depth the region's kind implies. This is the whole
128 + // reason `makeover-tui` is a dependency rather than a nice-to-have: a
129 + // raised region is drawn the same way here as in every other terminal app
130 + // in the tree, bevel included, and the depth comes off the vocabulary
131 + // rather than off this renderer's taste.
132 + let depth = slot.kind.depth();
133 + let inner = if framed(depth) {
134 + frame(buf, area, depth, tui.palette())
135 + } else {
136 + area
137 + };
138 +
139 + // `Readiness` is the loading axis, and a terminal has no spinner that is
140 + // not a clock. It says so in words instead, which loses the motion and
141 + // keeps the fact.
142 + if matches!(slot.readiness, layout::Readiness::Pending) {
143 + let used = text::draw(
144 + "Loading",
145 + Style::default()
146 + .fg(tui.theme().content_muted)
147 + .add_modifier(Modifier::ITALIC),
148 + inner,
149 + buf,
150 + );
151 + return used + if framed(depth) { 2 } else { 0 };
152 + }
153 +
154 + // A bespoke region is the host's. The description named the place and the
155 + // blocks it owns above the fill, so those draw; what the host puts under
156 + // them is the host's to draw, and this renderer has no fill mechanism to
157 + // offer it. That is a gap rather than a decline: `Webview::with_fill` has
158 + // no counterpart here.
159 + let mut used = 0;
160 + for node in &slot.body {
161 + used += crate::node::draw(tui, node, below(inner, used), buf);
162 + }
163 +
164 + // `Slot::id` is not drawn anywhere. It is a fragment address, and a
165 + // terminal redraws rather than swapping, so it costs nothing and says
166 + // nothing here.
167 + used + if framed(depth) { 2 } else { 0 }
168 + }
169 +
170 + /// Whether a depth is drawn with a border.
171 + ///
172 + /// Flat is not: a band and a plain pane are arrangement, and boxing every one
173 + /// of them spends two rows and two columns per region on a screen that is
174 + /// mostly regions.
175 + fn framed(depth: layout::Depth) -> bool {
176 + !matches!(depth, layout::Depth::Flat)
177 + }
178 +
179 + /// Split `area` into a left column of `width` and the rest.
180 + fn split(area: Rect, width: u16) -> (Rect, Rect) {
181 + let width = width.min(area.width);
182 + (
183 + Rect { width, ..area },
184 + Rect {
185 + x: area.x + width,
186 + width: area.width - width,
187 + ..area
188 + },
189 + )
190 + }
191 +
192 + /// Half the width and half the height, in the middle.
193 + fn centred(area: Rect) -> Rect {
194 + let width = area.width / 2;
195 + let height = area.height / 2;
196 + Rect {
197 + x: area.x + width / 2,
198 + y: area.y + height / 2,
199 + width,
200 + height,
201 + }
202 + }
@@ -1,0 +1,213 @@
1 + //! What a terminal draws from a description.
2 + //!
3 + //! Assertions are over the buffer's text rather than over styles, for the
4 + //! reason the webview's tests assert on classes rather than on colours: the
5 + //! colour is the theme's answer and changes with it, and what a renderer owes
6 + //! is that the words are there and in the right place.
7 +
8 + use makeover_layout as layout;
9 + use makeover_tui::{Fidelity, Theme};
10 + use quasi_router::{
11 + Act, Action, Cells, Choice, Column, Field, Figure, Meter, Node, RegionKind, Row, Screen, Slot,
12 + Tag,
13 + };
14 + use ratatui::buffer::Buffer;
15 + use ratatui::layout::Rect;
16 +
17 + use crate::Tui;
18 +
19 + /// A renderer in a shipped theme, at full colour.
20 + ///
21 + /// Through `Theme::from_theme` and a bundled theme file rather than a literal,
22 + /// because `makeover_tui::Theme` is `#[non_exhaustive]` and that is the only
23 + /// way to get one. A test that could build a partial theme by hand would be a
24 + /// test drawing in colours no theme ships.
25 + fn tui() -> Tui {
26 + let dir = makeover::bundled_themes_dir().expect("makeover ships themes");
27 + let colours = makeover::load_theme(&[(dir, false)], "goingson").expect("a bundled theme loads");
28 + Tui::new(
29 + Theme::from_theme(&colours).expect("a shipped theme resolves"),
30 + Fidelity::TrueColor,
31 + )
32 + }
33 +
34 + /// Everything a buffer holds, as one string per row.
35 + fn rows(buf: &Buffer) -> Vec<String> {
36 + (0..buf.area.height)
37 + .map(|y| {
38 + (0..buf.area.width)
39 + .map(|x| buf[(x, y)].symbol())
40 + .collect::<String>()
41 + .trim_end()
42 + .to_string()
43 + })
44 + .collect()
45 + }
46 +
47 + /// Draw one node into a buffer of this size.
48 + fn drawn(node: &Node, width: u16, height: u16) -> Vec<String> {
49 + let area = Rect::new(0, 0, width, height);
50 + let mut buf = Buffer::empty(area);
51 + tui().node(node, area, &mut buf);
52 + rows(&buf)
53 + }
54 +
55 + /// Draw a whole screen.
56 + fn shown(screen: &Screen, width: u16, height: u16) -> Vec<String> {
57 + let area = Rect::new(0, 0, width, height);
58 + let mut buf = Buffer::empty(area);
59 + tui().screen(screen, area, &mut buf);
60 + rows(&buf)
61 + }
62 +
63 + #[test]
64 + fn a_row_draws_its_run_in_the_order_the_description_says_it() {
65 + // The property the containment migration bought this renderer: a terminal
66 + // reads the run rather than knowing the old fixed member sequence.
67 + let out = drawn(
68 + &Node::list([Row::new("Ship it")
69 + .token(Tag::badge("beta"))
70 + .meta("2 files")]),
71 + 40,
72 + 3,
73 + );
74 + let line = &out[0];
75 + assert!(line.contains("Ship it"), "{out:?}");
76 + assert!(
77 + line.find("beta").unwrap() < line.find("2 files").unwrap(),
78 + "{out:?}"
79 + );
80 + }
81 +
82 + #[test]
83 + fn a_selectable_row_draws_its_tick_and_a_current_row_its_marker() {
84 + let ticked = drawn(&Node::list([Row::new("One").selectable(true)]), 20, 2);
85 + assert!(ticked[0].starts_with("[x]"), "{ticked:?}");
86 +
87 + let untickable = drawn(&Node::list([Row::new("One")]), 20, 2);
88 + assert!(!untickable[0].contains("[x]"), "{untickable:?}");
89 + }
90 +
91 + #[test]
92 + fn a_meter_is_a_bar_and_a_reading() {
93 + let out = drawn(&Node::Meter(Meter::new(3, 6).label("subtasks")), 40, 2);
94 + assert!(out[0].starts_with("#####-----"), "{out:?}");
95 + assert!(out[0].contains("3/6 subtasks"), "{out:?}");
96 + }
97 +
98 + #[test]
99 + fn a_figure_puts_the_number_over_what_it_counts() {
100 + let out = drawn(&Node::Figure(Figure::new("17", "Current streak")), 30, 3);
101 + assert_eq!(out[0], "17");
102 + assert_eq!(out[1], "Current streak");
103 + }
104 +
105 + #[test]
106 + fn a_strip_of_figures_stacks_rather_than_sitting_in_a_row() {
107 + // The renderer deciding, which the node leaves it free to do: the strip
108 + // says these belong together and not how wide they are.
109 + let out = drawn(
110 + &Node::stats([Figure::new("17", "Streak"), Figure::new("4", "Today")]),
111 + 30,
112 + 5,
113 + );
114 + assert_eq!(out[0], "17");
115 + assert_eq!(out[2], "4");
116 + }
117 +
118 + #[test]
119 + fn markdown_becomes_words_and_loses_its_emphasis() {
120 + // A finding, asserted so it stays visible: the node carries source so every
121 + // renderer can answer it its own way, and a terminal's own way is currently
122 + // to drop the emphasis a cell could have carried as bold.
123 + let out = drawn(&Node::rich("**ship it** now"), 40, 2);
124 + assert!(out[0].contains("ship it now"), "{out:?}");
125 + assert!(!out[0].contains('*'), "{out:?}");
126 + }
127 +
128 + #[test]
129 + fn a_hidden_field_draws_nothing_at_all() {
130 + let field = Field::new(layout::FieldKind::Hidden, "token", "Token");
131 + let out = drawn(&Node::Field(Box::new(field)), 30, 3);
132 + assert!(out.iter().all(String::is_empty), "{out:?}");
133 + }
134 +
135 + #[test]
136 + fn a_secret_field_has_nothing_to_draw_and_that_is_a_finding() {
137 + // `Field::value` drops what it is handed when the kind is `Secret`, on
138 + // purpose. A webview never noticed, because the browser owns the contents
139 + // of an `input` and redraws them itself. A terminal owns nothing, so what
140 + // the user typed lives in the runtime's buffer, and this is the first node
141 + // whose drawing is not a function of the description alone.
142 + let field = Field::new(layout::FieldKind::Secret, "password", "Password").value("hunter2");
143 + assert_eq!(field.value, None);
144 +
145 + let out = drawn(&Node::Field(Box::new(field)), 30, 3);
146 + assert_eq!(out[0], "Password");
147 + assert!(!out[1].contains("hunter2"), "{out:?}");
148 + }
149 +
150 + #[test]
151 + fn a_choice_field_marks_the_chosen_option() {
152 + let field = Field::select(
153 + "priority",
154 + "Priority",
155 + vec![Choice::plain("high"), Choice::plain("low")],
156 + )
157 + .value("low");
158 + let out = drawn(&Node::Field(Box::new(field)), 30, 4);
159 + assert_eq!(out[1], "( ) high");
160 + assert_eq!(out[2], "(*) low");
161 + }
162 +
163 + #[test]
164 + fn an_act_draws_its_key_because_the_description_carries_one() {
165 + // `Act::key` is the one place the vocabulary already anticipated a
166 + // terminal, and this is the renderer that finally reads it.
167 + let act = Act::new("Delete", Action::post("/tasks/1/delete")).key("d");
168 + let out = drawn(&Node::Act(act), 30, 2);
169 + assert!(out[0].contains("Delete"), "{out:?}");
170 + assert!(out[0].contains("(d)"), "{out:?}");
171 + }
172 +
173 + #[test]
174 + fn a_table_narrows_by_dropping_the_columns_that_said_they_could_go() {
175 + let table = Node::Table {
176 + columns: vec![
177 + Column::new("Name").priority(layout::Priority::Essential),
178 + Column::new("Added").priority(layout::Priority::Optional),
179 + ],
180 + rows: vec![Cells::new(["kick.wav", "2026-08-12"])],
181 + };
182 +
183 + let wide = drawn(&table, 60, 3);
184 + assert!(wide[0].contains("Added"), "{wide:?}");
185 +
186 + let narrow = drawn(&table, 14, 3);
187 + assert!(!narrow[0].contains("Added"), "{narrow:?}");
188 + }
189 +
190 + #[test]
191 + fn a_notice_belongs_to_the_screen_and_lands_above_every_region() {
192 + let screen = Screen::sidebar_content("Tasks")
193 + .saying(Node::Notice {
194 + kind: layout::Notice::Banner,
195 + tone: layout::Tone::Success,
196 + text: "Saved".into(),
197 + })
198 + .with(Slot::new("main", RegionKind::Pane).with(Node::section("Today")));
199 +
200 + let out = shown(&screen, 40, 6);
201 + assert_eq!(out[0], "Saved");
202 + assert!(out.iter().any(|row| row.contains("Today")), "{out:?}");
203 + }
204 +
205 + #[test]
206 + fn a_pending_region_says_so_in_words() {
207 + let slot = Slot::new("detail", RegionKind::Pane)
208 + .with(Node::text("Ready"))
209 + .pending();
210 + let out = drawn(&Node::Region(slot), 30, 3);
211 + assert!(out.iter().any(|row| row.contains("Loading")), "{out:?}");
212 + assert!(!out.iter().any(|row| row.contains("Ready")), "{out:?}");
213 + }
@@ -1,0 +1,147 @@
1 + //! Words into cells.
2 + //!
3 + //! A terminal wraps on words and counts rows, and both halves have to agree or
4 + //! a node draws over the one under it. So the wrap is written once here and
5 + //! both [`height`] and [`draw`] read it, rather than each having its own idea
6 + //! of how many rows a paragraph takes.
7 + //!
8 + //! Width is counted in `char`s. That is wrong for a terminal in the general
9 + //! case -- a CJK glyph occupies two cells and a combining mark none -- and it
10 + //! is deliberately not fixed here: the fix is a `unicode-width` dependency, and
11 + //! taking one before anything in the tree has non-ASCII content to draw is
12 + //! paying for a problem nobody has yet. Filed rather than hidden.
13 +
14 + use ratatui::buffer::Buffer;
15 + use ratatui::layout::Rect;
16 + use ratatui::style::Style;
17 + use ratatui::text::Line;
18 +
19 + /// Break `text` into lines no wider than `width`.
20 + ///
21 + /// Breaks on whitespace, and breaks inside a word only when the word cannot fit
22 + /// on a line of its own. A word longer than the whole width is the case that
23 + /// has no good answer; cutting it is the least bad one, because the alternative
24 + /// is a line wider than the region and a buffer that swallows the overflow
25 + /// silently.
26 + pub(crate) fn wrap(text: &str, width: u16) -> Vec<String> {
27 + if width == 0 {
28 + return Vec::new();
29 + }
30 + let width = width as usize;
31 + let mut lines = Vec::new();
32 +
33 + // Authored breaks are breaks. A description that put a newline in a string
34 + // meant it, and rewrapping across it would join two paragraphs.
35 + for paragraph in text.split('\n') {
36 + let mut line = String::new();
37 + for word in paragraph.split_whitespace() {
38 + let mut word = word;
39 + // A word too long for any line, cut to fit rather than overflowed.
40 + while word.chars().count() > width {
41 + if !line.is_empty() {
42 + lines.push(std::mem::take(&mut line));
43 + }
44 + let cut = word
45 + .char_indices()
46 + .nth(width)
47 + .map_or(word.len(), |(index, _)| index);
48 + lines.push(word[..cut].to_string());
49 + word = &word[cut..];
50 + }
51 + let room = width - line.chars().count();
52 + let wanted = word.chars().count() + usize::from(!line.is_empty());
53 + if wanted > room && !line.is_empty() {
54 + lines.push(std::mem::take(&mut line));
55 + }
56 + if !line.is_empty() {
57 + line.push(' ');
58 + }
59 + line.push_str(word);
60 + }
61 + lines.push(line);
62 + }
63 +
64 + // An empty string is no rows rather than one blank one, so a node with
65 + // nothing to say costs nothing. A blank line inside a paragraph survives,
66 + // because that one was authored.
67 + if lines.len() == 1 && lines[0].is_empty() {
68 + return Vec::new();
69 + }
70 + lines
71 + }
72 +
73 + /// The rows `text` takes at `width`.
74 + pub(crate) fn height(text: &str, width: u16) -> u16 {
75 + u16::try_from(wrap(text, width).len()).unwrap_or(u16::MAX)
76 + }
77 +
78 + /// Draw wrapped text at the top of `area`, and answer the rows it used.
79 + pub(crate) fn draw(text: &str, style: Style, area: Rect, buf: &mut Buffer) -> u16 {
80 + let mut used = 0;
81 + for line in wrap(text, area.width) {
82 + if used >= area.height {
83 + break;
84 + }
85 + buf.set_stringn(area.x, area.y + used, &line, area.width as usize, style);
86 + used += 1;
87 + }
88 + used
89 + }
90 +
91 + /// Draw a line of spans at the top of `area`, wrapping onto further rows.
92 + ///
93 + /// Spans carry their own styles, so this cannot go through [`wrap`]: the break
94 + /// has to be found without losing which span each word came from. It breaks
95 + /// between spans and, inside a span, on whitespace.
96 + pub(crate) fn draw_line(line: &Line<'_>, area: Rect, buf: &mut Buffer) -> u16 {
97 + let mut row = 0;
98 + let mut column = 0u16;
99 +
100 + for span in &line.spans {
101 + for word in span.content.split_whitespace() {
102 + let wanted = u16::try_from(word.chars().count()).unwrap_or(u16::MAX);
103 + let space = u16::from(column > 0);
104 + if column + space + wanted > area.width && column > 0 {
105 + row += 1;
106 + column = 0;
107 + }
108 + if row >= area.height {
109 + return area.height;
110 + }
111 + if column > 0 {
112 + buf.set_stringn(area.x + column, area.y + row, " ", 1, span.style);
113 + column += 1;
114 + }
115 + let room = area.width.saturating_sub(column) as usize;
116 + buf.set_stringn(area.x + column, area.y + row, word, room, span.style);
117 + column += wanted.min(area.width.saturating_sub(column));
118 + }
119 + }
120 +
121 + if column > 0 || row > 0 { row + 1 } else { 0 }
122 + }
123 +
124 + /// The rows a line of spans takes at `width`.
125 + pub(crate) fn line_height(line: &Line<'_>, width: u16) -> u16 {
126 + if width == 0 {
127 + return 0;
128 + }
129 + let mut rows = 0u16;
130 + let mut column = 0u16;
131 + let mut any = false;
132 +
133 + for span in &line.spans {
134 + for word in span.content.split_whitespace() {
135 + any = true;
136 + let wanted = u16::try_from(word.chars().count()).unwrap_or(u16::MAX);
137 + let space = u16::from(column > 0);
138 + if column + space + wanted > width && column > 0 {
139 + rows += 1;
140 + column = 0;
141 + }
142 + column += space + wanted;
143 + }
144 + }
145 +
146 + if any { rows + 1 } else { 0 }
147 + }