max / quasi
8 files changed,
+1557 insertions,
-12 deletions
| @@ -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" |
| @@ -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 | + | } |