max / quasi
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
9 files changed,
+2038 insertions,
-0 deletions
| @@ -2,12 +2,33 @@ | |||
| 2 | 2 | # It is not intended for manual editing. | |
| 3 | 3 | version = 4 | |
| 4 | 4 | ||
| 5 | + | [[package]] | |
| 6 | + | name = "accesskit" | |
| 7 | + | version = "0.24.1" | |
| 8 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 9 | + | checksum = "d3b7f7f85a7e5f68090000ed7622545829afd484d210358702ae4cb97dd0c320" | |
| 10 | + | dependencies = [ | |
| 11 | + | "uuid", | |
| 12 | + | ] | |
| 13 | + | ||
| 5 | 14 | [[package]] | |
| 6 | 15 | name = "adler2" | |
| 7 | 16 | version = "2.0.1" | |
| 8 | 17 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 9 | 18 | checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" | |
| 10 | 19 | ||
| 20 | + | [[package]] | |
| 21 | + | name = "ahash" | |
| 22 | + | version = "0.8.12" | |
| 23 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 24 | + | checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" | |
| 25 | + | dependencies = [ | |
| 26 | + | "cfg-if", | |
| 27 | + | "once_cell", | |
| 28 | + | "version_check", | |
| 29 | + | "zerocopy", | |
| 30 | + | ] | |
| 31 | + | ||
| 11 | 32 | [[package]] | |
| 12 | 33 | name = "aho-corasick" | |
| 13 | 34 | version = "1.1.5" | |
| @@ -65,6 +86,12 @@ | |||
| 65 | 86 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 66 | 87 | checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" | |
| 67 | 88 | ||
| 89 | + | [[package]] | |
| 90 | + | name = "arrayvec" | |
| 91 | + | version = "0.7.8" | |
| 92 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 93 | + | checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" | |
| 94 | + | ||
| 68 | 95 | [[package]] | |
| 69 | 96 | name = "atk" | |
| 70 | 97 | version = "0.18.2" | |
| @@ -253,6 +280,20 @@ | |||
| 253 | 280 | version = "1.25.2" | |
| 254 | 281 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 255 | 282 | checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" | |
| 283 | + | dependencies = [ | |
| 284 | + | "bytemuck_derive", | |
| 285 | + | ] | |
| 286 | + | ||
| 287 | + | [[package]] | |
| 288 | + | name = "bytemuck_derive" | |
| 289 | + | version = "1.12.0" | |
| 290 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 291 | + | checksum = "fc0e56a716f1e132ff6bf4bdac1c944a3fcdc1cae65f70a4a2a1ac3b401d2d1f" | |
| 292 | + | dependencies = [ | |
| 293 | + | "proc-macro2", | |
| 294 | + | "quote", | |
| 295 | + | "syn 3.0.3", | |
| 296 | + | ] | |
| 256 | 297 | ||
| 257 | 298 | [[package]] | |
| 258 | 299 | name = "byteorder" | |
| @@ -400,6 +441,15 @@ | |||
| 400 | 441 | "windows-link 0.2.1", | |
| 401 | 442 | ] | |
| 402 | 443 | ||
| 444 | + | [[package]] | |
| 445 | + | name = "color" | |
| 446 | + | version = "0.3.3" | |
| 447 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 448 | + | checksum = "2ec7c5eb7a16992b1904d76c517d170ab353b0e0b3d5a0c81a8a0cd1037893cf" | |
| 449 | + | dependencies = [ | |
| 450 | + | "bytemuck", | |
| 451 | + | ] | |
| 452 | + | ||
| 403 | 453 | [[package]] | |
| 404 | 454 | name = "combine" | |
| 405 | 455 | version = "4.6.7" | |
| @@ -859,12 +909,60 @@ | |||
| 859 | 909 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 860 | 910 | checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" | |
| 861 | 911 | ||
| 912 | + | [[package]] | |
| 913 | + | name = "ecolor" | |
| 914 | + | version = "0.35.0" | |
| 915 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 916 | + | checksum = "6758be723a3f298bbfda4db75748bc2ba0abafe096b6383c7c32da264764fbc3" | |
| 917 | + | dependencies = [ | |
| 918 | + | "emath", | |
| 919 | + | ] | |
| 920 | + | ||
| 921 | + | [[package]] | |
| 922 | + | name = "egui" | |
| 923 | + | version = "0.35.0" | |
| 924 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 925 | + | checksum = "2796c98d50b79631281d516343a6f6e93c0666462ca36e2c93b39f25d7793325" | |
| 926 | + | dependencies = [ | |
| 927 | + | "accesskit", | |
| 928 | + | "ahash", | |
| 929 | + | "bitflags 2.13.1", | |
| 930 | + | "emath", | |
| 931 | + | "epaint", | |
| 932 | + | "itertools", | |
| 933 | + | "log", | |
| 934 | + | "nohash-hasher", | |
| 935 | + | "profiling", | |
| 936 | + | "smallvec", | |
| 937 | + | "unicode-segmentation", | |
| 938 | + | ] | |
| 939 | + | ||
| 940 | + | [[package]] | |
| 941 | + | name = "egui_extras" | |
| 942 | + | version = "0.35.0" | |
| 943 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 944 | + | checksum = "e2bd33be7338367bf21f54d62e069d58a5fb3ae033fa8bc6df7a77b9ef4cf957" | |
| 945 | + | dependencies = [ | |
| 946 | + | "ahash", | |
| 947 | + | "egui", | |
| 948 | + | "enum-map", | |
| 949 | + | "itertools", | |
| 950 | + | "log", | |
| 951 | + | "profiling", | |
| 952 | + | ] | |
| 953 | + | ||
| 862 | 954 | [[package]] | |
| 863 | 955 | name = "either" | |
| 864 | 956 | version = "1.17.0" | |
| 865 | 957 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 866 | 958 | checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d" | |
| 867 | 959 | ||
| 960 | + | [[package]] | |
| 961 | + | name = "emath" | |
| 962 | + | version = "0.35.0" | |
| 963 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 964 | + | checksum = "cd4ec073c9898516584d8c6cfdcee95b530b3d941cd5031ef4050aa36812308b" | |
| 965 | + | ||
| 868 | 966 | [[package]] | |
| 869 | 967 | name = "embed-resource" | |
| 870 | 968 | version = "3.0.11" | |
| @@ -885,6 +983,49 @@ | |||
| 885 | 983 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 886 | 984 | checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" | |
| 887 | 985 | ||
| 986 | + | [[package]] | |
| 987 | + | name = "enum-map" | |
| 988 | + | version = "2.7.3" | |
| 989 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 990 | + | checksum = "6866f3bfdf8207509a033af1a75a7b08abda06bbaaeae6669323fd5a097df2e9" | |
| 991 | + | dependencies = [ | |
| 992 | + | "enum-map-derive", | |
| 993 | + | ] | |
| 994 | + | ||
| 995 | + | [[package]] | |
| 996 | + | name = "enum-map-derive" | |
| 997 | + | version = "0.17.0" | |
| 998 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 999 | + | checksum = "f282cfdfe92516eb26c2af8589c274c7c17681f5ecc03c18255fe741c6aa64eb" | |
| 1000 | + | dependencies = [ | |
| 1001 | + | "proc-macro2", | |
| 1002 | + | "quote", | |
| 1003 | + | "syn 2.0.119", | |
| 1004 | + | ] | |
| 1005 | + | ||
| 1006 | + | [[package]] | |
| 1007 | + | name = "epaint" | |
| 1008 | + | version = "0.35.0" | |
| 1009 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1010 | + | checksum = "4e60a8888b51da911df23918fd7301359b1d43a406a0ff3b8863af093dd7fc6c" | |
| 1011 | + | dependencies = [ | |
| 1012 | + | "ahash", | |
| 1013 | + | "ecolor", | |
| 1014 | + | "emath", | |
| 1015 | + | "font-types", | |
| 1016 | + | "harfrust", | |
| 1017 | + | "log", | |
| 1018 | + | "nohash-hasher", | |
| 1019 | + | "parking_lot", | |
| 1020 | + | "profiling", | |
| 1021 | + | "self_cell", | |
| 1022 | + | "skrifa", | |
| 1023 | + | "smallvec", | |
| 1024 | + | "unicode-general-category", | |
| 1025 | + | "unicode-segmentation", | |
| 1026 | + | "vello_cpu", | |
| 1027 | + | ] | |
| 1028 | + | ||
| 888 | 1029 | [[package]] | |
| 889 | 1030 | name = "equivalent" | |
| 890 | 1031 | version = "1.0.2" | |
| @@ -902,6 +1043,15 @@ | |||
| 902 | 1043 | "typeid", | |
| 903 | 1044 | ] | |
| 904 | 1045 | ||
| 1046 | + | [[package]] | |
| 1047 | + | name = "euclid" | |
| 1048 | + | version = "0.22.14" | |
| 1049 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1050 | + | checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06" | |
| 1051 | + | dependencies = [ | |
| 1052 | + | "num-traits", | |
| 1053 | + | ] | |
| 1054 | + | ||
| 905 | 1055 | [[package]] | |
| 906 | 1056 | name = "fallible-iterator" | |
| 907 | 1057 | version = "0.3.0" | |
| @@ -929,6 +1079,12 @@ | |||
| 929 | 1079 | "simd-adler32", | |
| 930 | 1080 | ] | |
| 931 | 1081 | ||
| 1082 | + | [[package]] | |
| 1083 | + | name = "fearless_simd" | |
| 1084 | + | version = "0.4.1" | |
| 1085 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1086 | + | checksum = "b97b65636e5b9ef369943878ac74335ba1c55c1cb6adbf1e2c293c624248d693" | |
| 1087 | + | ||
| 932 | 1088 | [[package]] | |
| 933 | 1089 | name = "field-offset" | |
| 934 | 1090 | version = "0.3.6" | |
| @@ -967,6 +1123,15 @@ | |||
| 967 | 1123 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 968 | 1124 | checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" | |
| 969 | 1125 | ||
| 1126 | + | [[package]] | |
| 1127 | + | name = "font-types" | |
| 1128 | + | version = "0.11.3" | |
| 1129 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1130 | + | checksum = "5b38ad915f6dadd993ced50848a8291a543bd41ca62bc10740d5e64e2ab4cfd7" | |
| 1131 | + | dependencies = [ | |
| 1132 | + | "bytemuck", | |
| 1133 | + | ] | |
| 1134 | + | ||
| 970 | 1135 | [[package]] | |
| 971 | 1136 | name = "foreign-types" | |
| 972 | 1137 | version = "0.5.0" | |
| @@ -1305,6 +1470,22 @@ | |||
| 1305 | 1470 | "system-deps", | |
| 1306 | 1471 | ] | |
| 1307 | 1472 | ||
| 1473 | + | [[package]] | |
| 1474 | + | name = "glifo" | |
| 1475 | + | version = "0.1.1" | |
| 1476 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1477 | + | checksum = "d99fc21d493812643aae86d53b7bbd02f376434a90317e8a790bc209fdd6605e" | |
| 1478 | + | dependencies = [ | |
| 1479 | + | "bytemuck", | |
| 1480 | + | "foldhash", | |
| 1481 | + | "hashbrown 0.17.1", | |
| 1482 | + | "log", | |
| 1483 | + | "peniko", | |
| 1484 | + | "skrifa", | |
| 1485 | + | "smallvec", | |
| 1486 | + | "vello_common", | |
| 1487 | + | ] | |
| 1488 | + | ||
| 1308 | 1489 | [[package]] | |
| 1309 | 1490 | name = "glob" | |
| 1310 | 1491 | version = "0.3.4" | |
| @@ -1374,6 +1555,27 @@ | |||
| 1374 | 1555 | "syn 2.0.119", | |
| 1375 | 1556 | ] | |
| 1376 | 1557 | ||
| 1558 | + | [[package]] | |
| 1559 | + | name = "guillotiere" | |
| 1560 | + | version = "0.7.0" | |
| 1561 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1562 | + | checksum = "6b17e70c989c36bad147b27a58d148c0741c51448aa5653436547323e524d0ab" | |
| 1563 | + | dependencies = [ | |
| 1564 | + | "euclid", | |
| 1565 | + | ] | |
| 1566 | + | ||
| 1567 | + | [[package]] | |
| 1568 | + | name = "harfrust" | |
| 1569 | + | version = "0.7.0" | |
| 1570 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1571 | + | checksum = "0431e8e389aa0f1e72bb9d1c2db8957a1a7a3580e8ed97db819c14837aac9b3e" | |
| 1572 | + | dependencies = [ | |
| 1573 | + | "bitflags 2.13.1", | |
| 1574 | + | "bytemuck", | |
| 1575 | + | "read-fonts", | |
| 1576 | + | "smallvec", | |
| 1577 | + | ] | |
| 1578 | + | ||
| 1377 | 1579 | [[package]] | |
| 1378 | 1580 | name = "hashbrown" | |
| 1379 | 1581 | version = "0.12.3" | |
| @@ -1949,6 +2151,18 @@ | |||
| 1949 | 2151 | "unicode-segmentation", | |
| 1950 | 2152 | ] | |
| 1951 | 2153 | ||
| 2154 | + | [[package]] | |
| 2155 | + | name = "kurbo" | |
| 2156 | + | version = "0.13.1" | |
| 2157 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2158 | + | checksum = "4b60dfc32f652b926df6192e55525b16d186c69d47876c3ead4da5cc9f8450e2" | |
| 2159 | + | dependencies = [ | |
| 2160 | + | "arrayvec", | |
| 2161 | + | "euclid", | |
| 2162 | + | "polycool", | |
| 2163 | + | "smallvec", | |
| 2164 | + | ] | |
| 2165 | + | ||
| 1952 | 2166 | [[package]] | |
| 1953 | 2167 | name = "libappindicator" | |
| 1954 | 2168 | version = "0.9.0" | |
| @@ -2027,6 +2241,12 @@ | |||
| 2027 | 2241 | "bitflags 2.13.1", | |
| 2028 | 2242 | ] | |
| 2029 | 2243 | ||
| 2244 | + | [[package]] | |
| 2245 | + | name = "linebender_resource_handle" | |
| 2246 | + | version = "0.1.1" | |
| 2247 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2248 | + | checksum = "d4a5ff6bcca6c4867b1c4fd4ef63e4db7436ef363e0ad7531d1558856bae64f4" | |
| 2249 | + | ||
| 2030 | 2250 | [[package]] | |
| 2031 | 2251 | name = "litemap" | |
| 2032 | 2252 | version = "0.8.2" | |
| @@ -2074,6 +2294,17 @@ | |||
| 2074 | 2294 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2075 | 2295 | checksum = "91a82b16c4f5b66dd7e87ed92fff7d22185e4d88e5355de6166dd64450cf6a16" | |
| 2076 | 2296 | ||
| 2297 | + | [[package]] | |
| 2298 | + | name = "makeover-immediate" | |
| 2299 | + | version = "0.18.0" | |
| 2300 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2301 | + | checksum = "333aefe3ef5c2c8df2df1fc7adf7a54613afaf58107e89916b0d0d6ca488dd6e" | |
| 2302 | + | dependencies = [ | |
| 2303 | + | "egui", | |
| 2304 | + | "egui_extras", | |
| 2305 | + | "makeover-layout", | |
| 2306 | + | ] | |
| 2307 | + | ||
| 2077 | 2308 | [[package]] | |
| 2078 | 2309 | name = "makeover-layout" | |
| 2079 | 2310 | version = "0.19.0" | |
| @@ -2239,6 +2470,12 @@ | |||
| 2239 | 2470 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2240 | 2471 | checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" | |
| 2241 | 2472 | ||
| 2473 | + | [[package]] | |
| 2474 | + | name = "nohash-hasher" | |
| 2475 | + | version = "0.2.0" | |
| 2476 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2477 | + | checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" | |
| 2478 | + | ||
| 2242 | 2479 | [[package]] | |
| 2243 | 2480 | name = "num-conv" | |
| 2244 | 2481 | version = "0.2.2" | |
| @@ -2531,6 +2768,19 @@ | |||
| 2531 | 2768 | "windows-link 0.2.1", | |
| 2532 | 2769 | ] | |
| 2533 | 2770 | ||
| 2771 | + | [[package]] | |
| 2772 | + | name = "peniko" | |
| 2773 | + | version = "0.6.1" | |
| 2774 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2775 | + | checksum = "839c8299360d2e998bdb106dc0a6cd71dcc5f4df51df1b620361bf50e283cca6" | |
| 2776 | + | dependencies = [ | |
| 2777 | + | "bytemuck", | |
| 2778 | + | "color", | |
| 2779 | + | "kurbo", | |
| 2780 | + | "linebender_resource_handle", | |
| 2781 | + | "smallvec", | |
| 2782 | + | ] | |
| 2783 | + | ||
| 2534 | 2784 | [[package]] | |
| 2535 | 2785 | name = "percent-encoding" | |
| 2536 | 2786 | version = "2.3.2" | |
| @@ -2641,6 +2891,15 @@ | |||
| 2641 | 2891 | "miniz_oxide", | |
| 2642 | 2892 | ] | |
| 2643 | 2893 | ||
| 2894 | + | [[package]] | |
| 2895 | + | name = "polycool" | |
| 2896 | + | version = "0.4.0" | |
| 2897 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2898 | + | checksum = "50596ddc09eb5ad5f75cacd40209568e66df71baf86e1499a0e99c4cff12a5a6" | |
| 2899 | + | dependencies = [ | |
| 2900 | + | "arrayvec", | |
| 2901 | + | ] | |
| 2902 | + | ||
| 2644 | 2903 | [[package]] | |
| 2645 | 2904 | name = "portable-atomic" | |
| 2646 | 2905 | version = "1.15.0" | |
| @@ -2739,6 +2998,12 @@ | |||
| 2739 | 2998 | "unicode-ident", | |
| 2740 | 2999 | ] | |
| 2741 | 3000 | ||
| 3001 | + | [[package]] | |
| 3002 | + | name = "profiling" | |
| 3003 | + | version = "1.0.18" | |
| 3004 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3005 | + | checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" | |
| 3006 | + | ||
| 2742 | 3007 | [[package]] | |
| 2743 | 3008 | name = "pulldown-cmark" | |
| 2744 | 3009 | version = "0.13.4" | |
| @@ -2785,6 +3050,16 @@ | |||
| 2785 | 3050 | "quasi-router", | |
| 2786 | 3051 | ] | |
| 2787 | 3052 | ||
| 3053 | + | [[package]] | |
| 3054 | + | name = "quasi-immediate" | |
| 3055 | + | version = "0.1.0" | |
| 3056 | + | dependencies = [ | |
| 3057 | + | "docengine", | |
| 3058 | + | "egui", | |
| 3059 | + | "makeover-immediate", | |
| 3060 | + | "quasi-router", | |
| 3061 | + | ] | |
| 3062 | + | ||
| 2788 | 3063 | [[package]] | |
| 2789 | 3064 | name = "quasi-router" | |
| 2790 | 3065 | version = "0.2.0" | |
| @@ -2920,6 +3195,16 @@ | |||
| 2920 | 3195 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2921 | 3196 | checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" | |
| 2922 | 3197 | ||
| 3198 | + | [[package]] | |
| 3199 | + | name = "read-fonts" | |
| 3200 | + | version = "0.39.2" | |
| 3201 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3202 | + | checksum = "c4ed38b89c2c77ff968c524145ad65fb010f38af5c7a224b53b81d47ac2daa81" | |
| 3203 | + | dependencies = [ | |
| 3204 | + | "bytemuck", | |
| 3205 | + | "font-types", | |
| 3206 | + | ] | |
| 3207 | + | ||
| 2923 | 3208 | [[package]] | |
| 2924 | 3209 | name = "redox_syscall" | |
| 2925 | 3210 | version = "0.5.18" | |
| @@ -3160,6 +3445,12 @@ | |||
| 3160 | 3445 | "smallvec", | |
| 3161 | 3446 | ] | |
| 3162 | 3447 | ||
| 3448 | + | [[package]] | |
| 3449 | + | name = "self_cell" | |
| 3450 | + | version = "1.3.0" | |
| 3451 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3452 | + | checksum = "2ab42ca02749e120097e328d91d415325bdf43b1c72c4c8badf37375fe40a813" | |
| 3453 | + | ||
| 3163 | 3454 | [[package]] | |
| 3164 | 3455 | name = "semver" | |
| 3165 | 3456 | version = "1.0.28" | |
| @@ -3381,6 +3672,16 @@ | |||
| 3381 | 3672 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3382 | 3673 | checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" | |
| 3383 | 3674 | ||
| 3675 | + | [[package]] | |
| 3676 | + | name = "skrifa" | |
| 3677 | + | version = "0.42.1" | |
| 3678 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3679 | + | checksum = "0c34617370ae968efb7161bb2beb517d9084659aae19e24b89e3db25b46e4564" | |
| 3680 | + | dependencies = [ | |
| 3681 | + | "bytemuck", | |
| 3682 | + | "read-fonts", | |
| 3683 | + | ] | |
| 3684 | + | ||
| 3384 | 3685 | [[package]] | |
| 3385 | 3686 | name = "slab" | |
| 3386 | 3687 | version = "0.4.12" | |
| @@ -4300,6 +4601,12 @@ | |||
| 4300 | 4601 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 4301 | 4602 | checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" | |
| 4302 | 4603 | ||
| 4604 | + | [[package]] | |
| 4605 | + | name = "unicode-general-category" | |
| 4606 | + | version = "1.1.0" | |
| 4607 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 4608 | + | checksum = "0b993bddc193ae5bd0d623b49ec06ac3e9312875fdae725a975c51db1cc1677f" | |
| 4609 | + | ||
| 4303 | 4610 | [[package]] | |
| 4304 | 4611 | name = "unicode-ident" | |
| 4305 | 4612 | version = "1.0.24" | |
| @@ -4378,6 +4685,34 @@ | |||
| 4378 | 4685 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 4379 | 4686 | checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" | |
| 4380 | 4687 | ||
| 4688 | + | [[package]] | |
| 4689 | + | name = "vello_common" | |
| 4690 | + | version = "0.0.9" | |
| 4691 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 4692 | + | checksum = "19d672facaa2d697285a786cd9d44d614cd2ce54cdc022504bf339f8fff3b750" | |
| 4693 | + | dependencies = [ | |
| 4694 | + | "bytemuck", | |
| 4695 | + | "fearless_simd", | |
| 4696 | + | "guillotiere", | |
| 4697 | + | "hashbrown 0.17.1", | |
| 4698 | + | "log", | |
| 4699 | + | "peniko", | |
| 4700 | + | "smallvec", | |
| 4701 | + | "thiserror 2.0.20", | |
| 4702 | + | ] | |
| 4703 | + | ||
| 4704 | + | [[package]] | |
| 4705 | + | name = "vello_cpu" | |
| 4706 | + | version = "0.0.9" | |
| 4707 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 4708 | + | checksum = "588691169aed86b5c8fb487266afee01323234e6fd0a3f2aaec0eaa8e4007f23" | |
| 4709 | + | dependencies = [ | |
| 4710 | + | "bytemuck", | |
| 4711 | + | "glifo", | |
| 4712 | + | "hashbrown 0.17.1", | |
| 4713 | + | "vello_common", | |
| 4714 | + | ] | |
| 4715 | + | ||
| 4381 | 4716 | [[package]] | |
| 4382 | 4717 | name = "version-compare" | |
| 4383 | 4718 | version = "0.2.1" | |
| @@ -5099,6 +5434,26 @@ | |||
| 5099 | 5434 | "synstructure", | |
| 5100 | 5435 | ] | |
| 5101 | 5436 | ||
| 5437 | + | [[package]] | |
| 5438 | + | name = "zerocopy" | |
| 5439 | + | version = "0.8.56" | |
| 5440 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 5441 | + | checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" | |
| 5442 | + | dependencies = [ |
Lines truncated
| @@ -8,6 +8,7 @@ | |||
| 8 | 8 | "crates/quasi-store", | |
| 9 | 9 | "crates/quasi-tauri", | |
| 10 | 10 | "crates/quasi-tui", | |
| 11 | + | "crates/quasi-immediate", | |
| 11 | 12 | "crates/quasi-webview", | |
| 12 | 13 | ] | |
| 13 | 14 | # The scaffolder's template is real Rust and real manifests rather than liquid, |
| @@ -1,0 +1,30 @@ | |||
| 1 | + | [package] | |
| 2 | + | name = "quasi-immediate" | |
| 3 | + | version = "0.1.0" | |
| 4 | + | edition = "2024" | |
| 5 | + | description = "The immediate-mode renderer for quasi-router: a described screen in, an egui frame out. Immediate mode is the constraint that matters, not the library." | |
| 6 | + | license = "MIT" | |
| 7 | + | repository = "https://makenot.work/git/max/quasi" | |
| 8 | + | readme = "README.md" | |
| 9 | + | keywords = ["egui", "gui", "immediate-mode", "renderer"] | |
| 10 | + | categories = ["gui"] | |
| 11 | + | ||
| 12 | + | [dependencies] | |
| 13 | + | quasi-router = { path = "../quasi-router", version = "0.2.0" } | |
| 14 | + | # The node drawing, which is the makeover layer's and not this crate's. Every | |
| 15 | + | # widget here that is not a container comes from it: a screen walk that painted | |
| 16 | + | # its own meter would be the divergence the suite exists to end, one copy per | |
| 17 | + | # renderer instead of one copy per app. | |
| 18 | + | makeover-immediate = "0.18.0" | |
| 19 | + | egui = { version = "0.35", default-features = false } | |
| 20 | + | # Markdown to text for `Node::Rich`. The same crate the webview renderer takes, | |
| 21 | + | # reading the same source: what differs is that it can emit markup and this | |
| 22 | + | # cannot, so this asks for the plain rendering. | |
| 23 | + | docengine = { git = "https://makenot.work/git/max/docengine.git", version = "0.7" } | |
| 24 | + | ||
| 25 | + | [lints.rust] | |
| 26 | + | unused = "warn" | |
| 27 | + | unreachable_pub = "warn" | |
| 28 | + | ||
| 29 | + | [lints.clippy] | |
| 30 | + | pedantic = { level = "warn", priority = -1 } |
| @@ -1,0 +1,203 @@ | |||
| 1 | + | //! The immediate-mode renderer for [`quasi_router`]: a described screen in, an | |
| 2 | + | //! egui frame out. | |
| 3 | + | //! | |
| 4 | + | //! <!-- wiki: quasi-overview --> | |
| 5 | + | //! | |
| 6 | + | //! The third renderer, and the one the stack was missing. `quasi-webview` and | |
| 7 | + | //! `quasi-tui` have consumed a [`Screen`] since the beginning; nothing did in | |
| 8 | + | //! egui, so an egui app describing a screen had nowhere to send it. What looked | |
| 9 | + | //! like the egui renderer was `makeover-immediate`, which draws described | |
| 10 | + | //! *nodes* and takes no dependency on `quasi-router` at all: it is the peer of | |
| 11 | + | //! `makeover-webview` and `makeover-tui`, one layer below this. | |
| 12 | + | //! | |
| 13 | + | //! | Layer | webview | terminal | egui | | |
| 14 | + | //! |---|---|---|---| | |
| 15 | + | //! | nodes, from `makeover-layout` | `makeover-webview` | `makeover-tui` | `makeover-immediate` | | |
| 16 | + | //! | screens, from `quasi-router` | `quasi-webview` | `quasi-tui` | **this crate** | | |
| 17 | + | //! | |
| 18 | + | //! Named for the mode and not the library, the way `makeover-immediate` is: what | |
| 19 | + | //! separates this renderer from the other two is that there is no retained tree | |
| 20 | + | //! and no cascade, and egui is the backend it is written against. | |
| 21 | + | //! | |
| 22 | + | //! # What immediate mode gives this renderer for free | |
| 23 | + | //! | |
| 24 | + | //! `quasi-tui` holds five things a browser provides quietly: what is typed, what | |
| 25 | + | //! has focus, how far a pane is scrolled, what is ticked, and where back goes. | |
| 26 | + | //! egui provides three of them, so this crate is smaller than the terminal's | |
| 27 | + | //! rather than larger: | |
| 28 | + | //! | |
| 29 | + | //! - **Reach and focus are egui's**, entirely. Its own id stack decides what is | |
| 30 | + | //! reachable and its own state decides what holds the keyboard, which is the | |
| 31 | + | //! rule `makeover-immediate`'s header already states for the ring. There is no | |
| 32 | + | //! `focus.rs` here and there should not be one. | |
| 33 | + | //! - **Scroll is egui's**, through `ScrollArea`. | |
| 34 | + | //! - **What is typed and what is ticked are not.** A described field is built | |
| 35 | + | //! from the description every frame, so the buffer behind it has to outlive | |
| 36 | + | //! the frame and belongs to the app. That is [`View`], and it is the same | |
| 37 | + | //! discovery the terminal made for the same reason. | |
| 38 | + | //! | |
| 39 | + | //! # Where a description stops being enough | |
| 40 | + | //! | |
| 41 | + | //! The three findings `quasi-tui`'s `region` module records apply here unchanged, | |
| 42 | + | //! because they are about the description rather than about terminals: a tabbed | |
| 43 | + | //! arrangement does not say which tab is showing, a tab has no label, and | |
| 44 | + | //! nothing says a region's share beyond [`Arrangement::share`]. Nothing new is | |
| 45 | + | //! invented here to paper over them; the same guesses are made and named. | |
| 46 | + | ||
| 47 | + | #![forbid(unsafe_code)] | |
| 48 | + | ||
| 49 | + | mod node; | |
| 50 | + | mod region; | |
| 51 | + | mod runtime; | |
| 52 | + | mod view; | |
| 53 | + | ||
| 54 | + | #[cfg(test)] | |
| 55 | + | mod tests; | |
| 56 | + | ||
| 57 | + | pub use runtime::{Runtime, Step}; | |
| 58 | + | pub use view::View; | |
| 59 | + | ||
| 60 | + | use egui::Ui; | |
| 61 | + | use makeover_immediate::table::TableStyle; | |
| 62 | + | use makeover_immediate::widget::WidgetStyle; | |
| 63 | + | use makeover_immediate::{FieldStyle, FrameStyle, Palette}; | |
| 64 | + | use quasi_router::{Action, Message, Params, Screen, layout}; | |
| 65 | + | ||
| 66 | + | /// A banner this renderer raises about itself. | |
| 67 | + | /// | |
| 68 | + | /// A description bug reported to the user rather than swallowed: a fragment | |
| 69 | + | /// naming a region that is not there would otherwise look like a control that | |
| 70 | + | /// does nothing. | |
| 71 | + | pub(crate) fn layout_notice(text: String) -> Message { | |
| 72 | + | Message { | |
| 73 | + | kind: layout::Notice::Banner, | |
| 74 | + | tone: layout::Tone::Danger, | |
| 75 | + | text, | |
| 76 | + | undo: None, | |
| 77 | + | } | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | /// An egui renderer for a described screen. | |
| 81 | + | /// | |
| 82 | + | /// Holds what a drawing needs and no screen state: the resolved palette and the | |
| 83 | + | /// styles derived from it. A host makes one and keeps it, the same way it keeps | |
| 84 | + | /// a `Tui`. | |
| 85 | + | #[derive(Debug, Clone)] | |
| 86 | + | pub struct Immediate { | |
| 87 | + | palette: Palette, | |
| 88 | + | frame: FrameStyle, | |
| 89 | + | field: FieldStyle, | |
| 90 | + | widget: WidgetStyle, | |
| 91 | + | table: TableStyle, | |
| 92 | + | } | |
| 93 | + | ||
| 94 | + | impl Immediate { | |
| 95 | + | /// A renderer drawing in this palette, with default styling. | |
| 96 | + | #[must_use] | |
| 97 | + | pub fn new(palette: Palette) -> Self { | |
| 98 | + | Self { | |
| 99 | + | palette, | |
| 100 | + | frame: FrameStyle::default(), | |
| 101 | + | field: FieldStyle::default(), | |
| 102 | + | widget: WidgetStyle::default(), | |
| 103 | + | table: TableStyle::default(), | |
| 104 | + | } | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | /// The colours this renderer draws in. | |
| 108 | + | #[must_use] | |
| 109 | + | pub const fn palette(&self) -> &Palette { | |
| 110 | + | &self.palette | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | /// Use these frame, field, widget and table styles, chaining. | |
| 114 | + | #[must_use] | |
| 115 | + | pub fn styled( | |
| 116 | + | mut self, | |
| 117 | + | frame: FrameStyle, | |
| 118 | + | field: FieldStyle, | |
| 119 | + | widget: WidgetStyle, | |
| 120 | + | table: TableStyle, | |
| 121 | + | ) -> Self { | |
| 122 | + | self.frame = frame; | |
| 123 | + | self.field = field; | |
| 124 | + | self.widget = widget; | |
| 125 | + | self.table = table; | |
| 126 | + | self | |
| 127 | + | } | |
| 128 | + | ||
| 129 | + | /// Draw a whole screen, and answer what the user did to it. | |
| 130 | + | /// | |
| 131 | + | /// The title is not drawn, for the reason `quasi-tui` gives: a window title | |
| 132 | + | /// is the host's to set, the same way a webview host puts it in `<title>` | |
| 133 | + | /// rather than in the document. [`Screen::discovery`] is declined outright, | |
| 134 | + | /// since nothing crawls a desktop window. | |
| 135 | + | /// | |
| 136 | + | /// `None` is the ordinary frame. egui redraws continuously, so most frames | |
| 137 | + | /// are a user doing nothing, and a renderer that answered a request per | |
| 138 | + | /// frame would call the router sixty times a second. | |
| 139 | + | pub fn screen(&self, ui: &mut Ui, screen: &Screen, view: &mut View) -> Option<Fired> { | |
| 140 | + | let mut pass = Pass { | |
| 141 | + | immediate: self, | |
| 142 | + | view, | |
| 143 | + | fired: None, | |
| 144 | + | }; | |
| 145 | + | ||
| 146 | + | // Notices first and at the top, because a notice belongs to the screen | |
| 147 | + | // rather than to a place in it. A webview leaves where they land to the | |
| 148 | + | // stylesheet; egui has none, so this is the renderer deciding, and the | |
| 149 | + | // top is the one place a message about the whole screen can go without | |
| 150 | + | // claiming a region. | |
| 151 | + | for notice in &screen.notices { | |
| 152 | + | node::draw(&mut pass, ui, notice); | |
| 153 | + | } | |
| 154 | + | ||
| 155 | + | region::screen_regions(&mut pass, ui, screen); | |
| 156 | + | pass.fired | |
| 157 | + | } | |
| 158 | + | } | |
| 159 | + | ||
| 160 | + | /// What the user set off this frame. | |
| 161 | + | /// | |
| 162 | + | /// An action and everything gathered to send with it. Separate from | |
| 163 | + | /// [`Step`](runtime::Step) because a drawing does not know whether an action | |
| 164 | + | /// needs asking about first: that is [`Act::confirm`](quasi_router::Act), and | |
| 165 | + | /// the runtime is what holds the question while it is answered. | |
| 166 | + | #[derive(Debug, Clone, PartialEq, Eq)] | |
| 167 | + | pub struct Fired { | |
| 168 | + | /// What to call. | |
| 169 | + | pub action: Action, | |
| 170 | + | /// What to send with it: a form's values, a selection's members. | |
| 171 | + | pub payload: Params, | |
| 172 | + | /// What to ask before doing it, if the description said to ask. | |
| 173 | + | pub confirm: Option<String>, | |
| 174 | + | } | |
| 175 | + | ||
| 176 | + | /// One frame's drawing, and what came out of it. | |
| 177 | + | /// | |
| 178 | + | /// Threaded through the walk rather than returned up it: a press can happen at | |
| 179 | + | /// any depth, and every level returning an `Option` would make each node's | |
| 180 | + | /// drawing responsible for propagating one. | |
| 181 | + | pub(crate) struct Pass<'a> { | |
| 182 | + | pub(crate) immediate: &'a Immediate, | |
| 183 | + | pub(crate) view: &'a mut View, | |
| 184 | + | pub(crate) fired: Option<Fired>, | |
| 185 | + | } | |
| 186 | + | ||
| 187 | + | impl Pass<'_> { | |
| 188 | + | /// Record what the user set off. | |
| 189 | + | /// | |
| 190 | + | /// First press wins. Two controls cannot be activated in one frame by a | |
| 191 | + | /// user, so a second one here is a description that put two `Response`s | |
| 192 | + | /// under one click, and taking the first is the same rule | |
| 193 | + | /// `Chrome::bound` uses for a key claimed twice. | |
| 194 | + | pub(crate) fn fire(&mut self, action: &Action, payload: Params, confirm: Option<&str>) { | |
| 195 | + | if self.fired.is_none() { | |
| 196 | + | self.fired = Some(Fired { | |
| 197 | + | action: action.clone(), | |
| 198 | + | payload, | |
| 199 | + | confirm: confirm.map(ToOwned::to_owned), | |
| 200 | + | }); | |
| 201 | + | } | |
| 202 | + | } | |
| 203 | + | } |
| @@ -1,0 +1,438 @@ | |||
| 1 | + | //! Nodes to egui. | |
| 2 | + | //! | |
| 3 | + | //! Every function here takes a piece of [`quasi_router`]'s screen tree and draws | |
| 4 | + | //! it into a `Ui`. Nothing returns a `Result`: a description that exists is | |
| 5 | + | //! renderable by construction, which is the property the owned mirror in | |
| 6 | + | //! `quasi-router` was built to have. | |
| 7 | + | //! | |
| 8 | + | //! # Where the drawing goes | |
| 9 | + | //! | |
| 10 | + | //! Down, into `makeover-immediate`. A meter, a token, a control, a figure, a | |
| 11 | + | //! field and a table are all its, and this module is the walk that decides which | |
| 12 | + | //! one a node is and where it sits. The split is the same one `quasi-tui` keeps | |
| 13 | + | //! against `makeover-tui`, and it is what stops a second copy of the vocabulary's | |
| 14 | + | //! drawing existing per host. | |
| 15 | + | //! | |
| 16 | + | //! What is left here is what a `Screen` adds over a node: the address a control | |
| 17 | + | //! carries, the values a form gathers, and the selection a row's tick joins. | |
| 18 | + | //! None of that is `makeover-layout`'s, so none of it can be down there. | |
| 19 | + | ||
| 20 | + | use egui::{RichText, Ui}; | |
| 21 | + | use makeover_immediate::widget; | |
| 22 | + | use makeover_immediate::{Filling, field, frame}; | |
| 23 | + | use quasi_router::layout; | |
| 24 | + | use quasi_router::{Act, Action, Node, Params, Row, Slot}; | |
| 25 | + | ||
| 26 | + | use crate::Pass; | |
| 27 | + | ||
| 28 | + | /// Draw one node. | |
| 29 | + | /// | |
| 30 | + | /// Exhaustive, with no catch-all arm, because [`Node`] carries no | |
| 31 | + | /// `#[non_exhaustive]` and that is deliberate upstream: a node added to the | |
| 32 | + | /// vocabulary should stop every renderer compiling until each has decided what | |
| 33 | + | /// it looks like. The same argument `Outcome` documents, one layer down. A | |
| 34 | + | /// wildcard here would convert that into a screen that silently draws less than | |
| 35 | + | /// it describes. | |
| 36 | + | pub(crate) fn draw(pass: &mut Pass<'_>, ui: &mut Ui, node: &Node) { | |
| 37 | + | match node { | |
| 38 | + | Node::Heading { level, text } => { | |
| 39 | + | let size = ui.text_style_height(&egui::TextStyle::Body) | |
| 40 | + | * match level { | |
| 41 | + | layout::Heading::Page => 1.6, | |
| 42 | + | layout::Heading::Section => 1.3, | |
| 43 | + | layout::Heading::Subsection => 1.1, | |
| 44 | + | }; | |
| 45 | + | ui.label( | |
| 46 | + | RichText::new(text) | |
| 47 | + | .size(size) | |
| 48 | + | .strong() | |
| 49 | + | .color(pass.immediate.palette.content), | |
| 50 | + | ); | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | Node::Text { text, .. } => { | |
| 54 | + | ui.label(RichText::new(text).color(pass.immediate.palette.content)); | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | // Markdown arrives as source, so that every renderer answers it its own | |
| 58 | + | // way. This one has no rich text of its own worth the name, so it takes | |
| 59 | + | // the plain rendering: `**bold**` reads as `bold` rather than as four | |
| 60 | + | // characters of syntax, which is the outcome `Node::Text` would have | |
| 61 | + | // given anyway and is the honest floor until egui grows a markdown | |
| 62 | + | // widget worth adopting. | |
| 63 | + | Node::Rich { source, .. } => { | |
| 64 | + | ui.label( | |
| 65 | + | RichText::new(docengine::render_plain(source)) | |
| 66 | + | .color(pass.immediate.palette.content), | |
| 67 | + | ); | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | Node::Act(act) => { | |
| 71 | + | act_node(pass, ui, act, None); | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | Node::Link { text, action, .. } => { | |
| 75 | + | // A link is a link and not a button: egui has `Link`, and a control | |
| 76 | + | // that navigates should not look like one that writes. | |
| 77 | + | if ui | |
| 78 | + | .link(RichText::new(text).color(pass.immediate.palette.action)) | |
| 79 | + | .clicked() | |
| 80 | + | { | |
| 81 | + | pass.fire(action, Params::new(), None); | |
| 82 | + | } | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | Node::Figure(figure) => { | |
| 86 | + | widget::figure( | |
| 87 | + | ui, | |
| 88 | + | &figure.as_layout(), | |
| 89 | + | &pass.immediate.palette, | |
| 90 | + | &pass.immediate.widget, | |
| 91 | + | ); | |
| 92 | + | } | |
| 93 | + | ||
| 94 | + | Node::Token(tag) => { | |
| 95 | + | let pressed = widget::token( | |
| 96 | + | ui, | |
| 97 | + | &tag.label, | |
| 98 | + | tag.kind, | |
| 99 | + | tag.tone, | |
| 100 | + | tag.latched, | |
| 101 | + | &pass.immediate.palette, | |
| 102 | + | &pass.immediate.widget, | |
| 103 | + | ); | |
| 104 | + | if let Some(action) = &tag.action | |
| 105 | + | && pressed.clicked() | |
| 106 | + | { | |
| 107 | + | pass.fire(action, Params::new(), None); | |
| 108 | + | } | |
| 109 | + | } | |
| 110 | + | ||
| 111 | + | Node::Notice { tone, text, .. } => { | |
| 112 | + | // The tone carries it, and the surface says it is a thing set on | |
| 113 | + | // the page rather than part of the flow. Where a toast lands | |
| 114 | + | // against a banner is renderer policy and this renderer has one | |
| 115 | + | // place to put either, which is where the caller drew it. | |
| 116 | + | frame( | |
| 117 | + | ui, | |
| 118 | + | layout::Depth::Raised, | |
| 119 | + | &pass.immediate.palette, | |
| 120 | + | pass.immediate.frame, | |
| 121 | + | |ui| { | |
| 122 | + | ui.label(RichText::new(text).color(pass.immediate.palette.tone(*tone))); | |
| 123 | + | }, | |
| 124 | + | ); | |
| 125 | + | } | |
| 126 | + | ||
| 127 | + | Node::StandIn { message, act, .. } => { | |
| 128 | + | ui.label(RichText::new(message).color(pass.immediate.palette.content_muted)); | |
| 129 | + | if let Some(act) = act { | |
| 130 | + | act_node(pass, ui, act, None); | |
| 131 | + | } | |
| 132 | + | } | |
| 133 | + | ||
| 134 | + | other => container(pass, ui, other), | |
| 135 | + | } | |
| 136 | + | } | |
| 137 | + | ||
| 138 | + | /// The nodes that hold other nodes, or that a screen's own facts reach into. | |
| 139 | + | /// | |
| 140 | + | /// Split from [`draw`] where the line fell naturally rather than to satisfy a | |
| 141 | + | /// lint: everything above is a leaf that needs the palette and nothing else, | |
| 142 | + | /// and everything here needs the view, the selection or a nested walk. | |
| 143 | + | fn container(pass: &mut Pass<'_>, ui: &mut Ui, node: &Node) { | |
| 144 | + | match node { | |
| 145 | + | Node::Field(described) => { | |
| 146 | + | field_node(pass, ui, described); | |
| 147 | + | } | |
| 148 | + | ||
| 149 | + | Node::Region(slot) => { | |
| 150 | + | region(pass, ui, slot); | |
| 151 | + | } | |
| 152 | + | ||
| 153 | + | Node::Form { | |
| 154 | + | fields, | |
| 155 | + | submit, | |
| 156 | + | action, | |
| 157 | + | .. | |
| 158 | + | } => { | |
| 159 | + | form(pass, ui, fields, submit, action); | |
| 160 | + | } | |
| 161 | + | ||
| 162 | + | Node::List { rows, more, .. } => { | |
| 163 | + | for row in rows { | |
| 164 | + | list_row(pass, ui, row); | |
| 165 | + | } | |
| 166 | + | if let Some(rest) = more { | |
| 167 | + | // The count where the router knew one. `Rest::remaining` is | |
| 168 | + | // often `None`, which is the honest case: a list that cannot | |
| 169 | + | // say how many more there are still has a way to ask for them. | |
| 170 | + | let label = match rest.remaining { | |
| 171 | + | Some(n) => format!("Show {n} more"), | |
| 172 | + | None => "Show more".to_owned(), | |
| 173 | + | }; | |
| 174 | + | if ui.button(label).clicked() { | |
| 175 | + | pass.fire(&rest.action, Params::new(), None); | |
| 176 | + | } | |
| 177 | + | } | |
| 178 | + | } | |
| 179 | + | ||
| 180 | + | Node::Table { .. } => { | |
| 181 | + | // Deliberately not drawn yet, and said rather than skipped | |
| 182 | + | // silently. `makeover_immediate::table` draws one, and what it | |
| 183 | + | // takes is a body closure per cell; wiring a described table's | |
| 184 | + | // cells through that is the next node, not a line here. A caller | |
| 185 | + | // meeting this sees nothing and should file it rather than wonder. | |
| 186 | + | ui.label( | |
| 187 | + | RichText::new("(a described table is not drawn by this renderer yet)") | |
| 188 | + | .color(pass.immediate.palette.content_muted), | |
| 189 | + | ); | |
| 190 | + | } | |
| 191 | + | ||
| 192 | + | Node::Select { | |
| 193 | + | options, | |
| 194 | + | chosen, | |
| 195 | + | action, | |
| 196 | + | .. | |
| 197 | + | } => { | |
| 198 | + | select(pass, ui, options, chosen.as_deref(), action.as_ref()); | |
| 199 | + | } | |
| 200 | + | ||
| 201 | + | Node::Meter(meter) => { | |
| 202 | + | widget::meter( | |
| 203 | + | ui, | |
| 204 | + | &meter.as_layout(), | |
| 205 | + | &pass.immediate.palette, | |
| 206 | + | &pass.immediate.widget, | |
| 207 | + | ); | |
| 208 | + | } | |
| 209 | + | ||
| 210 | + | Node::Stats { figures } => { | |
| 211 | + | // Across rather than down, which is the one thing a strip says: a | |
| 212 | + | // terminal stacks them because it has no width to spare, and a | |
| 213 | + | // window does. | |
| 214 | + | ui.horizontal(|ui| { | |
| 215 | + | for (figure, address) in figures { | |
| 216 | + | let shown = widget::figure( | |
| 217 | + | ui, | |
| 218 | + | &figure.as_layout(), | |
| 219 | + | &pass.immediate.palette, | |
| 220 | + | &pass.immediate.widget, | |
| 221 | + | ); | |
| 222 | + | // The one of goingson's five figure sites that renders its | |
| 223 | + | // value as a button: the description's half is the | |
| 224 | + | // vocabulary's and the optional address is quasi's. | |
| 225 | + | if let Some(action) = address | |
| 226 | + | && shown.interact(egui::Sense::click()).clicked() | |
| 227 | + | { | |
| 228 | + | pass.fire(action, Params::new(), None); | |
| 229 | + | } | |
| 230 | + | } | |
| 231 | + | }); | |
| 232 | + | } | |
| 233 | + | ||
| 234 | + | // Every leaf is answered by `draw`, which is exhaustive, so this | |
| 235 | + | // reaches nothing. It is here because the split is this crate's and | |
| 236 | + | // not the vocabulary's: `Node` still has no wildcard anywhere, and a | |
| 237 | + | // member added upstream still stops `draw` compiling. | |
| 238 | + | _ => unreachable!("a leaf reached the container walk"), | |
| 239 | + | } | |
| 240 | + | } | |
| 241 | + | ||
| 242 | + | /// A control, with whatever the screen wants gathered behind it. | |
| 243 | + | fn act_node(pass: &mut Pass<'_>, ui: &mut Ui, act: &Act, over: Option<&str>) { | |
| 244 | + | let described = act.as_layout(); | |
| 245 | + | let pressed = widget::act( | |
| 246 | + | ui, | |
| 247 | + | &described, | |
| 248 | + | &pass.immediate.palette, | |
| 249 | + | &pass.immediate.widget, | |
| 250 | + | ); | |
| 251 | + | if pressed.clicked() { | |
| 252 | + | let payload = over.map_or_else(Params::new, |under| pass.view.gathering(under)); | |
| 253 | + | pass.fire(&act.action, payload, act.confirm.as_deref()); | |
| 254 | + | } | |
| 255 | + | } | |
| 256 | + | ||
| 257 | + | /// One field, filled from the view rather than from the description. | |
| 258 | + | fn field_node(pass: &mut Pass<'_>, ui: &mut Ui, described: &quasi_router::Field) { | |
| 259 | + | let name = described.name.clone(); | |
| 260 | + | let kind = described.kind; | |
| 261 | + | let offered = described.value.clone(); | |
| 262 | + | ||
| 263 | + | if kind == layout::FieldKind::Checkbox { | |
| 264 | + | let mut on = pass | |
| 265 | + | .view | |
| 266 | + | .edit(&name) | |
| 267 | + | .map_or(offered.is_some(), |value| !value.is_empty()); | |
| 268 | + | let before = on; | |
| 269 | + | described.with_layout(|borrowed| { | |
| 270 | + | field( | |
| 271 | + | ui, | |
| 272 | + | &borrowed, | |
| 273 | + | Filling::On(&mut on), | |
| 274 | + | None, | |
| 275 | + | &pass.immediate.palette, | |
| 276 | + | &pass.immediate.field, | |
| 277 | + | ); | |
| 278 | + | }); | |
| 279 | + | if on != before { | |
| 280 | + | pass.view.set(&name, if on { "on" } else { "" }); | |
| 281 | + | if let Some(action) = &described.changes { | |
| 282 | + | let payload = Params::new().with(name, if on { "on" } else { "" }.to_owned()); | |
| 283 | + | pass.fire(action, payload, None); | |
| 284 | + | } | |
| 285 | + | } | |
| 286 | + | return; | |
| 287 | + | } | |
| 288 | + | ||
| 289 | + | // The buffer has to outlive the frame, so it is the view's. Taken out and | |
| 290 | + | // put back rather than borrowed across the closure, because the closure | |
| 291 | + | // also needs the palette off `pass`. | |
| 292 | + | let mut buffer = pass.view.buffer(&name, offered.as_deref()).clone(); | |
| 293 | + | let before = buffer.clone(); | |
| 294 | + | described.with_layout(|borrowed| { | |
| 295 | + | field( | |
| 296 | + | ui, | |
| 297 | + | &borrowed, | |
| 298 | + | Filling::Text(&mut buffer), | |
| 299 | + | None, | |
| 300 | + | &pass.immediate.palette, | |
| 301 | + | &pass.immediate.field, | |
| 302 | + | ); | |
| 303 | + | }); | |
| 304 | + | if buffer != before { | |
| 305 | + | pass.view.set(&name, buffer.clone()); | |
| 306 | + | if let Some(action) = &described.changes { | |
| 307 | + | let payload = Params::new().with(name, buffer); | |
| 308 | + | pass.fire(action, payload, None); | |
| 309 | + | } | |
| 310 | + | } | |
| 311 | + | } | |
| 312 | + | ||
| 313 | + | /// A form: its fields, then the one control that answers all of them. | |
| 314 | + | fn form( | |
| 315 | + | pass: &mut Pass<'_>, | |
| 316 | + | ui: &mut Ui, | |
| 317 | + | fields: &[quasi_router::Field], | |
| 318 | + | submit: &str, | |
| 319 | + | action: &Action, | |
| 320 | + | ) { | |
| 321 | + | for described in fields { | |
| 322 | + | field_node(pass, ui, described); | |
| 323 | + | } | |
| 324 | + | if ui.button(RichText::new(submit)).clicked() { | |
| 325 | + | let names: Vec<String> = fields.iter().map(|f| f.name.clone()).collect(); | |
| 326 | + | let described = fields | |
| 327 | + | .iter() | |
| 328 | + | .filter_map(|f| f.value.clone().map(|v| (f.name.clone(), v))) | |
| 329 | + | .collect(); | |
| 330 | + | let payload = pass.view.submission(&names, &described); | |
| 331 | + | pass.fire(action, payload, None); | |
| 332 | + | } | |
| 333 | + | } | |
| 334 | + | ||
| 335 | + | /// One row of a list. | |
| 336 | + | fn list_row(pass: &mut Pass<'_>, ui: &mut Ui, row: &Row) { | |
| 337 | + | ui.horizontal(|ui| { | |
| 338 | + | // The tick, where the row can carry one. `toggle` first, because a row | |
| 339 | + | // carrying one has said the tick *is* the write and that beats the | |
| 340 | + | // screen's staged set. | |
| 341 | + | if let Some(ticked) = row.selected { | |
| 342 | + | let mut on = row | |
| 343 | + | .value | |
| 344 | + | .as_ref() | |
| 345 | + | .map_or(ticked, |value| pass.view.is_ticked(value)); | |
| 346 | + | if ui.checkbox(&mut on, "").changed() { | |
| 347 | + | if let Some(action) = &row.toggle { | |
| 348 | + | pass.fire(action, Params::new(), None); | |
| 349 | + | } else if let Some(value) = &row.value { | |
| 350 | + | pass.view.tick(value); | |
| 351 | + | } | |
| 352 | + | } | |
| 353 | + | } | |
| 354 | + | ||
| 355 | + | for part in &row.parts { | |
| 356 | + | row_part(pass, ui, part); | |
| 357 | + | } | |
| 358 | + | }); | |
| 359 | + | ||
| 360 | + | // Opening the row is the row itself, and it is a control rather than a | |
| 361 | + | // click on the whole strip: egui has no `:hover` affordance to say a strip | |
| 362 | + | // is pressable, so the primary text is the target the way a list row's | |
| 363 | + | // anchor is in the webview. | |
| 364 | + | if let Some(action) = &row.activate | |
| 365 | + | && ui | |
| 366 | + | .interact( | |
| 367 | + | ui.min_rect(), | |
| 368 | + | ui.id().with(("row", row.value.as_deref().unwrap_or(""))), | |
| 369 | + | egui::Sense::click(), | |
| 370 | + | ) | |
| 371 | + | .clicked() | |
| 372 | + | { | |
| 373 | + | pass.fire(action, Params::new(), None); | |
| 374 | + | } | |
| 375 | + | } | |
| 376 | + | ||
| 377 | + | /// One part of a row's run. | |
| 378 | + | /// | |
| 379 | + | /// A part is a role and a node, so the drawing is `draw` again: the role says | |
| 380 | + | /// where it sits in the run and the node says what it is. That is the property | |
| 381 | + | /// the containment migration bought every renderer, and it is why a row does not | |
| 382 | + | /// need a second switch over member types here. | |
| 383 | + | fn row_part(pass: &mut Pass<'_>, ui: &mut Ui, part: &quasi_router::Part) { | |
| 384 | + | draw(pass, ui, &part.node); | |
| 385 | + | } | |
| 386 | + | ||
| 387 | + | /// A set of choices, one of which is picked. | |
| 388 | + | fn select( | |
| 389 | + | pass: &mut Pass<'_>, | |
| 390 | + | ui: &mut Ui, | |
| 391 | + | options: &[(quasi_router::Choice, Option<Action>)], | |
| 392 | + | chosen: Option<&str>, | |
| 393 | + | action: Option<&Action>, | |
| 394 | + | ) { | |
| 395 | + | ui.horizontal(|ui| { | |
| 396 | + | for (choice, own) in options { | |
| 397 | + | let picked = chosen == Some(choice.value.as_str()); | |
| 398 | + | if ui.selectable_label(picked, &choice.label).clicked() { | |
| 399 | + | // An option naming its own route beats the strip's, which is | |
| 400 | + | // the half `makeover-layout` added the pair for: a tab strip | |
| 401 | + | // addressing one panel out of fifteen cannot be one route with | |
| 402 | + | // a value substituted in. | |
| 403 | + | if let Some(action) = own.as_ref().or(action) { | |
| 404 | + | let payload = | |
| 405 | + | Params::new().with(Node::SELECTED.to_owned(), choice.value.clone()); | |
| 406 | + | pass.fire(action, payload, None); | |
| 407 | + | } | |
| 408 | + | } | |
| 409 | + | } | |
| 410 | + | }); | |
| 411 | + | } | |
| 412 | + | ||
| 413 | + | /// A region, drawn as the surface its kind names. | |
| 414 | + | pub(crate) fn region(pass: &mut Pass<'_>, ui: &mut Ui, slot: &Slot) { | |
| 415 | + | // Readiness first: a region that is not ready has nothing to draw and says | |
| 416 | + | // so, which is the whole of what the axis is for. | |
| 417 | + | match slot.readiness { | |
| 418 | + | layout::Readiness::Pending => { | |
| 419 | + | ui.spinner(); | |
| 420 | + | return; | |
| 421 | + | } | |
| 422 | + | layout::Readiness::Failed => { | |
| 423 | + | ui.label( | |
| 424 | + | RichText::new("This did not load.") | |
| 425 | + | .color(pass.immediate.palette.tone(layout::Tone::Danger)), | |
| 426 | + | ); | |
| 427 | + | return; | |
| 428 | + | } | |
| 429 | + | // `Empty` is drawn: what says a region is empty is a `Node::StandIn` | |
| 430 | + | // inside it, per `703f4cd2`, because a column with a heading and no | |
| 431 | + | // rows still has content. | |
| 432 | + | _ => {} | |
| 433 | + | } | |
| 434 | + | ||
| 435 | + | for node in &slot.body { | |
| 436 | + | draw(pass, ui, node); | |
| 437 | + | } | |
| 438 | + | } |
| @@ -1,0 +1,128 @@ | |||
| 1 | + | //! Regions into panels. | |
| 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 it is | |
| 5 | + | //! the renderer's, and every place the description does not say enough to do it | |
| 6 | + | //! is a finding. | |
| 7 | + | //! | |
| 8 | + | //! **The same three findings `quasi-tui`'s `region` module records apply here, | |
| 9 | + | //! unchanged**, because they are about the description rather than about | |
| 10 | + | //! terminals. Repeated rather than cross-referenced only, since a reader here | |
| 11 | + | //! needs to know the guesses are guesses: | |
| 12 | + | //! | |
| 13 | + | //! - **A tabbed arrangement does not say which tab is showing.** This draws the | |
| 14 | + | //! first, which is a guess. | |
| 15 | + | //! - **A tab has no label.** [`Slot::id`] is an address chosen to be stable for | |
| 16 | + | //! fragment targeting, so using it as a heading puts `contacts-detail` on | |
| 17 | + | //! screen. | |
| 18 | + | //! - **A region's share is [`Arrangement::share`] and nothing finer.** | |
| 19 | + | //! | |
| 20 | + | //! What differs from the terminal is only the mechanism: `egui::SidePanel` and | |
| 21 | + | //! `Ui::columns` rather than rect arithmetic, and a `ScrollArea` per pane | |
| 22 | + | //! because scroll is egui's here and the terminal's there. | |
| 23 | + | ||
| 24 | + | use egui::Ui; | |
| 25 | + | use quasi_router::layout::Arrangement; | |
| 26 | + | use quasi_router::{RegionKind, Screen, Slot}; | |
| 27 | + | ||
| 28 | + | use crate::{Pass, node}; | |
| 29 | + | ||
| 30 | + | /// Lay a screen's regions out and draw them. | |
| 31 | + | pub(crate) fn screen_regions(pass: &mut Pass<'_>, ui: &mut Ui, screen: &Screen) { | |
| 32 | + | // Bands stack at the top, full width, in the order they were said. A band is | |
| 33 | + | // an arrangement rather than a type -- a page header, a toolbar -- so it | |
| 34 | + | // takes the height it needs and gets out of the way. | |
| 35 | + | for slot in screen | |
| 36 | + | .slots | |
| 37 | + | .iter() | |
| 38 | + | .filter(|slot| matches!(slot.kind, RegionKind::Band)) | |
| 39 | + | { | |
| 40 | + | node::region(pass, ui, slot); | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | let body: Vec<&Slot> = screen | |
| 44 | + | .slots | |
| 45 | + | .iter() | |
| 46 | + | .filter(|slot| !matches!(slot.kind, RegionKind::Band)) | |
| 47 | + | .collect(); | |
| 48 | + | ||
| 49 | + | match screen.arrangement { | |
| 50 | + | Arrangement::SidebarContent { share } => { | |
| 51 | + | let sidebar = body | |
| 52 | + | .iter() | |
| 53 | + | .position(|slot| matches!(slot.kind, RegionKind::Sidebar)); | |
| 54 | + | match sidebar { | |
| 55 | + | Some(at) => { | |
| 56 | + | let width = ui.available_width() * share_of(share); | |
| 57 | + | ui.horizontal_top(|ui| { | |
| 58 | + | ui.allocate_ui(egui::vec2(width, ui.available_height()), |ui| { | |
| 59 | + | scrolled(ui, body[at], pass); | |
| 60 | + | }); | |
| 61 | + | ui.vertical(|ui| { | |
| 62 | + | for (i, slot) in body.iter().enumerate() { | |
| 63 | + | if i != at { | |
| 64 | + | scrolled(ui, slot, pass); | |
| 65 | + | } | |
| 66 | + | } | |
| 67 | + | }); | |
| 68 | + | }); | |
| 69 | + | } | |
| 70 | + | // A sidebar arrangement with no sidebar region is a description | |
| 71 | + | // that named a shape it did not fill. Drawn down the page | |
| 72 | + | // rather than refused: the regions are real and the reader can | |
| 73 | + | // still use them. | |
| 74 | + | None => { | |
| 75 | + | for slot in &body { | |
| 76 | + | scrolled(ui, slot, pass); | |
| 77 | + | } | |
| 78 | + | } | |
| 79 | + | } | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | Arrangement::ListDetail { share, tabbed } => { | |
| 83 | + | if tabbed { | |
| 84 | + | // The guess named in the header: nothing says which of the two | |
| 85 | + | // is showing, so the first is. | |
| 86 | + | if let Some(slot) = body.first() { | |
| 87 | + | scrolled(ui, slot, pass); | |
| 88 | + | } | |
| 89 | + | } else { | |
| 90 | + | let left = ui.available_width() * share_of(share); | |
| 91 | + | ui.horizontal_top(|ui| { | |
| 92 | + | if let Some(slot) = body.first() { | |
| 93 | + | ui.allocate_ui(egui::vec2(left, ui.available_height()), |ui| { | |
| 94 | + | scrolled(ui, slot, pass); | |
| 95 | + | }); | |
| 96 | + | } | |
| 97 | + | ui.vertical(|ui| { | |
| 98 | + | for slot in body.iter().skip(1) { | |
| 99 | + | scrolled(ui, slot, pass); | |
| 100 | + | } | |
| 101 | + | }); | |
| 102 | + | }); | |
| 103 | + | } | |
| 104 | + | } | |
| 105 | + | } | |
| 106 | + | } | |
| 107 | + | ||
| 108 | + | /// A pane with its own scroll. | |
| 109 | + | /// | |
| 110 | + | /// Scroll is egui's here, which is one of the three things `quasi-tui`'s `View` | |
| 111 | + | /// holds and this crate's does not. The id is the slot's, so a pane keeps its | |
| 112 | + | /// position across frames the way the description's stable ids intend. | |
| 113 | + | fn scrolled(ui: &mut Ui, slot: &Slot, pass: &mut Pass<'_>) { | |
| 114 | + | egui::ScrollArea::vertical() | |
| 115 | + | .id_salt(&slot.id) | |
| 116 | + | .show(ui, |ui| { | |
| 117 | + | node::region(pass, ui, slot); | |
| 118 | + | }); | |
| 119 | + | } | |
| 120 | + | ||
| 121 | + | /// A share as a fraction of the width on offer. | |
| 122 | + | fn share_of(share: quasi_router::layout::Share) -> f32 { | |
| 123 | + | // `Share::of` answers in whole columns for a terminal. A window has | |
| 124 | + | // fractional width, so the ratio is taken against a large denominator and | |
| 125 | + | // divided back, which keeps one fact behind both renderers' proportions. | |
| 126 | + | const SCALE: u16 = 1000; | |
| 127 | + | f32::from(share.of(SCALE)) / f32::from(SCALE) | |
| 128 | + | } |
| @@ -1,0 +1,370 @@ | |||
| 1 | + | //! A screen, what the user has done to it, and how they got here. | |
| 2 | + | //! | |
| 3 | + | //! The counterpart of `quasi-tui`'s runtime, and it holds less: reach, focus and | |
| 4 | + | //! scroll are egui's, so what is left is history, the question a control asked, | |
| 5 | + | //! the overlays stacked over the screen, and the app's own chrome. | |
| 6 | + | //! | |
| 7 | + | //! # Keys | |
| 8 | + | //! | |
| 9 | + | //! There is no key table here, and that is the difference from the terminal | |
| 10 | + | //! rather than an omission. Tab, Enter, Space and the arrows are egui's own | |
| 11 | + | //! walk over its own reach; a renderer that bound them again would be a second | |
| 12 | + | //! party moving the keyboard. | |
| 13 | + | //! | |
| 14 | + | //! What is left is [`Chrome`], which egui cannot know about: an app-level | |
| 15 | + | //! binding is a key that belongs to no widget, so it is read off the context | |
| 16 | + | //! before the frame is drawn. | |
| 17 | + | ||
| 18 | + | use egui::Ui; | |
| 19 | + | use quasi_router::{ | |
| 20 | + | Action, Chrome, Message, Method, Node, Outcome, Params, Request, Response, Screen, | |
| 21 | + | }; | |
| 22 | + | ||
| 23 | + | use crate::{Fired, Immediate, View, layout_notice}; | |
| 24 | + | ||
| 25 | + | /// What the host should do next. | |
| 26 | + | #[derive(Debug, Clone, PartialEq, Eq)] | |
| 27 | + | pub enum Step { | |
| 28 | + | /// Nothing left to do but draw again. | |
| 29 | + | Idle, | |
| 30 | + | /// Ask the router this, then hand the answer to [`Runtime::apply`]. | |
| 31 | + | Call(Request), | |
| 32 | + | /// Ask this question, then call [`Runtime::answer`] with what the user said. | |
| 33 | + | Ask(String), | |
| 34 | + | /// Somewhere outside the app. The host opens it, and nothing comes back. | |
| 35 | + | Open(String), | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | /// A screen and what the user has done to it. | |
| 39 | + | /// | |
| 40 | + | /// The pair an overlay needs: an overlay has its own edits and its own ticks, so | |
| 41 | + | /// it holds a [`View`] of its own rather than borrowing the one underneath. | |
| 42 | + | #[derive(Debug, Clone)] | |
| 43 | + | struct Layer { | |
| 44 | + | screen: Screen, | |
| 45 | + | view: View, | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | /// A screen, what the user has done to it, and how they got here. | |
| 49 | + | #[derive(Debug, Clone)] | |
| 50 | + | pub struct Runtime { | |
| 51 | + | screen: Screen, | |
| 52 | + | view: View, | |
| 53 | + | /// What the app offers from every screen. | |
| 54 | + | chrome: Chrome, | |
| 55 | + | /// The layers this one is drawn over, outermost first. | |
| 56 | + | /// | |
| 57 | + | /// Separate from `history` on purpose: an overlay is not a place. Opening | |
| 58 | + | /// one pushes here and leaves history alone, and dismissing one reveals the | |
| 59 | + | /// screen the user never left. | |
| 60 | + | under: Vec<Layer>, | |
| 61 | + | /// The places behind this one, most recent last. | |
| 62 | + | history: Vec<Request>, | |
| 63 | + | /// The request that produced the screen currently showing. | |
| 64 | + | here: Option<Request>, | |
| 65 | + | /// A control waiting on its own question being answered. | |
| 66 | + | asked: Option<(Action, Params)>, | |
| 67 | + | /// Something to say once the screen it belongs to has arrived. | |
| 68 | + | saying: Option<Message>, | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | impl Runtime { | |
| 72 | + | /// Start on this screen, with nothing typed and nothing behind it. | |
| 73 | + | #[must_use] | |
| 74 | + | pub fn new(screen: Screen) -> Self { | |
| 75 | + | let mut runtime = Self { | |
| 76 | + | screen, | |
| 77 | + | view: View::new(), | |
| 78 | + | chrome: Chrome::new(), | |
| 79 | + | under: Vec::new(), | |
| 80 | + | history: Vec::new(), | |
| 81 | + | here: None, | |
| 82 | + | asked: None, | |
| 83 | + | saying: None, | |
| 84 | + | }; | |
| 85 | + | runtime.view.seed(&runtime.screen); | |
| 86 | + | runtime | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | /// Declare what the app offers from every screen. | |
| 90 | + | #[must_use] | |
| 91 | + | pub fn with_chrome(mut self, chrome: Chrome) -> Self { | |
| 92 | + | self.chrome = chrome; | |
| 93 | + | self | |
| 94 | + | } | |
| 95 | + | ||
| 96 | + | /// The screen being shown: the overlay's, when one is open. | |
| 97 | + | #[must_use] | |
| 98 | + | pub const fn screen(&self) -> &Screen { | |
| 99 | + | &self.screen | |
| 100 | + | } | |
| 101 | + | ||
| 102 | + | /// Whether an overlay is open over the screen. | |
| 103 | + | #[must_use] | |
| 104 | + | pub const fn overlaid(&self) -> bool { | |
| 105 | + | !self.under.is_empty() | |
| 106 | + | } | |
| 107 | + | ||
| 108 | + | /// Draw a frame, and answer what to do about it. | |
| 109 | + | /// | |
| 110 | + | /// The chrome's keys are read before the drawing, so a screen cannot capture | |
| 111 | + | /// the key that opens the palette: the binding belongs to the app and the | |
| 112 | + | /// widgets below have not been laid out yet. | |
| 113 | + | pub fn show(&mut self, ui: &mut Ui, immediate: &Immediate) -> Step { | |
| 114 | + | if let Some(binding) = self.pressed_binding(ui.ctx()) { | |
| 115 | + | return Self::call(&binding); | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | // What is under it first, then this one over the top. An `Area` is what | |
| 119 | + | // an overlay is in egui, and `Order::Foreground` is what puts it there. | |
| 120 | + | for layer in &self.under { | |
| 121 | + | let mut pass = crate::Pass { | |
| 122 | + | immediate, | |
| 123 | + | view: &mut layer.view.clone(), | |
| 124 | + | fired: None, | |
| 125 | + | }; | |
| 126 | + | crate::region::screen_regions(&mut pass, ui, &layer.screen); | |
| 127 | + | } | |
| 128 | + | ||
| 129 | + | let fired = if self.under.is_empty() { | |
| 130 | + | immediate.screen(ui, &self.screen, &mut self.view) | |
| 131 | + | } else { | |
| 132 | + | let mut fired = None; | |
| 133 | + | egui::Area::new(ui.id().with("quasi-overlay")) | |
| 134 | + | .order(egui::Order::Foreground) | |
| 135 | + | .show(ui.ctx(), |ui| { | |
| 136 | + | egui::Frame::popup(ui.style()) | |
| 137 | + | .shadow(immediate.palette().cast()) | |
| 138 | + | .show(ui, |ui| { | |
| 139 | + | fired = immediate.screen(ui, &self.screen, &mut self.view); | |
| 140 | + | }); | |
| 141 | + | }); | |
| 142 | + | fired | |
| 143 | + | }; | |
| 144 | + | ||
| 145 | + | // Escape closes what is on top before it goes back, which is what | |
| 146 | + | // Escape means everywhere else it is bound. | |
| 147 | + | if ui.ctx().input(|i| i.key_pressed(egui::Key::Escape)) { | |
| 148 | + | if self.dismiss() { | |
| 149 | + | return Step::Idle; | |
| 150 | + | } | |
| 151 | + | return self.back(); | |
| 152 | + | } | |
| 153 | + | ||
| 154 | + | match fired { | |
| 155 | + | Some(Fired { | |
| 156 | + | action, | |
| 157 | + | payload, | |
| 158 | + | confirm, | |
| 159 | + | }) => match confirm { | |
| 160 | + | Some(prompt) => { | |
| 161 | + | self.asked = Some((action, payload)); | |
| 162 | + | Step::Ask(prompt) | |
| 163 | + | } | |
| 164 | + | None => Self::send(&action, payload), | |
| 165 | + | }, | |
| 166 | + | None => Step::Idle, | |
| 167 | + | } | |
| 168 | + | } | |
| 169 | + | ||
| 170 | + | /// Answer the question a control asked. | |
| 171 | + | /// | |
| 172 | + | /// Anything that is not yes is no, which is the safe way round for a prompt | |
| 173 | + | /// only ever raised by something destructive. | |
| 174 | + | pub fn answer(&mut self, yes: bool) -> Step { | |
| 175 | + | match self.asked.take() { | |
| 176 | + | Some((action, payload)) if yes => Self::send(&action, payload), | |
| 177 | + | _ => Step::Idle, | |
| 178 | + | } | |
| 179 | + | } | |
| 180 | + | ||
| 181 | + | /// Go back, if there is anywhere to go. | |
| 182 | + | pub fn back(&mut self) -> Step { | |
| 183 | + | match self.history.pop() { | |
| 184 | + | Some(request) => { | |
| 185 | + | self.here = Some(request.clone()); | |
| 186 | + | Step::Call(request) | |
| 187 | + | } | |
| 188 | + | None => Step::Idle, | |
| 189 | + | } | |
| 190 | + | } | |
| 191 | + | ||
| 192 | + | /// Close the overlay on top, if there is one. | |
| 193 | + | /// | |
| 194 | + | /// The layer underneath comes back exactly as it was left. History is not | |
| 195 | + | /// touched, because an overlay was never a place. | |
| 196 | + | fn dismiss(&mut self) -> bool { | |
| 197 | + | match self.under.pop() { | |
| 198 | + | Some(layer) => { | |
| 199 | + | self.screen = layer.screen; | |
| 200 | + | self.view = layer.view; | |
| 201 | + | true | |
| 202 | + | } | |
| 203 | + | None => false, | |
| 204 | + | } | |
| 205 | + | } | |
| 206 | + | ||
| 207 | + | /// The binding a key pressed this frame claims, if any. | |
| 208 | + | fn pressed_binding(&self, ctx: &egui::Context) -> Option<Action> { | |
| 209 | + | if self.chrome.bindings.is_empty() { | |
| 210 | + | return None; | |
| 211 | + | } | |
| 212 | + | ctx.input(|input| { | |
| 213 | + | self.chrome.bindings.iter().find_map(|binding| { | |
| 214 | + | let (key, modifiers) = parse(&binding.key)?; | |
| 215 | + | input | |
| 216 | + | .key_pressed(key) | |
| 217 | + | .then(|| input.modifiers.matches_logically(modifiers)) | |
| 218 | + | .and_then(|held| held.then(|| binding.action.clone())) | |
| 219 | + | }) | |
| 220 | + | }) | |
| 221 | + | } | |
| 222 | + | ||
| 223 | + | /// Put what the router answered onto the screen. | |
| 224 | + | pub fn apply(&mut self, request: &Request, response: Response) -> Option<Request> { | |
| 225 | + | let Response { | |
| 226 | + | outcome, | |
| 227 | + | notice, | |
| 228 | + | address, | |
| 229 | + | invalidates, | |
| 230 | + | } = response; | |
| 231 | + | self.saying = notice.or(self.saying.take()); | |
| 232 | + | ||
| 233 | + | match outcome { | |
| 234 | + | // Over what is already there. The layer underneath is put away | |
| 235 | + | // whole and comes back untouched when the overlay is dismissed. | |
| 236 | + | // `remember` is deliberately not called: an overlay is not a place. | |
| 237 | + | Outcome::Over(screen) => { | |
| 238 | + | let under = Layer { | |
| 239 | + | screen: std::mem::replace(&mut self.screen, screen), | |
| 240 | + | view: std::mem::replace(&mut self.view, View::new()), | |
| 241 | + | }; | |
| 242 | + | self.under.push(under); | |
| 243 | + | self.view.seed(&self.screen); | |
| 244 | + | self.announce(); | |
| 245 | + | None | |
| 246 | + | } | |
| 247 | + | Outcome::Screen(screen) => { | |
| 248 | + | // A navigation replaces everything, including any overlay open | |
| 249 | + | // over it. | |
| 250 | + | self.under.clear(); | |
| 251 | + | self.remember(request, address.as_ref()); | |
| 252 | + | self.screen = screen; | |
| 253 | + | self.view.reset(); | |
| 254 | + | self.view.seed(&self.screen); | |
| 255 | + | self.announce(); | |
| 256 | + | None | |
| 257 | + | } | |
| 258 | + | Outcome::Fragment { region, node } => { | |
| 259 | + | let mut missing: Vec<String> = Vec::new(); | |
| 260 | + | if !self.screen.replace(®ion, node) { | |
| 261 | + | missing.push(region); | |
| 262 | + | } | |
| 263 | + | for stale in invalidates { | |
| 264 | + | if !self.screen.replace(&stale.region, stale.node) { | |
| 265 | + | missing.push(stale.region); | |
| 266 | + | } | |
| 267 | + | } | |
| 268 | + | if !missing.is_empty() { | |
| 269 | + | let named = missing | |
| 270 | + | .iter() | |
| 271 | + | .map(|region| format!("`{region}`")) | |
| 272 | + | .collect::<Vec<_>>() | |
| 273 | + | .join(", "); | |
| 274 | + | let subject = if missing.len() == 1 { "is" } else { "are" }; | |
| 275 | + | self.saying = Some(layout_notice(format!( | |
| 276 | + | "{named} {subject} not on this screen" | |
| 277 | + | ))); | |
| 278 | + | } | |
| 279 | + | self.announce(); | |
| 280 | + | None | |
| 281 | + | } | |
| 282 | + | // Somewhere else, which the host performs the same way it | |
| 283 | + | // performed the first request. An external destination hands off | |
| 284 | + | // and nothing comes back. | |
| 285 | + | Outcome::Goto(action) => { | |
| 286 | + | let path = action.destination.route()?; | |
| 287 | + | let request = Request::get(path.to_string()).carrying(action.carried.clone()); | |
| 288 | + | self.remember(&request, None); | |
| 289 | + | Some(request) | |
| 290 | + | } | |
| 291 | + | } | |
| 292 | + | } | |
| 293 | + | ||
| 294 | + | /// Note where we were, before we leave it. | |
| 295 | + | fn remember(&mut self, request: &Request, address: Option<&quasi_router::Address>) { | |
| 296 | + | let place = match address { | |
| 297 | + | Some(quasi_router::Address::Enters(_)) => true, | |
| 298 | + | Some(quasi_router::Address::Unchanged) => false, | |
| 299 | + | Some(quasi_router::Address::Replaces(_)) => { | |
| 300 | + | self.here = Some(request.clone()); | |
| 301 | + | return; | |
| 302 | + | } | |
| 303 | + | None => request.method == Method::Get, | |
| 304 | + | }; | |
| 305 | + | if place && let Some(previous) = self.here.replace(request.clone()) { | |
| 306 | + | self.history.push(previous); | |
| 307 | + | } | |
| 308 | + | } | |
| 309 | + | ||
| 310 | + | /// Put whatever the last answer said onto the screen it belongs to. | |
| 311 | + | fn announce(&mut self) { | |
| 312 | + | if let Some(Message { | |
| 313 | + | kind, tone, text, .. | |
| 314 | + | }) = self.saying.take() | |
| 315 | + | { | |
| 316 | + | self.screen.notices.push(Node::Notice { kind, tone, text }); | |
| 317 | + | } | |
| 318 | + | } | |
| 319 | + | ||
| 320 | + | /// A control's action as the next step. | |
| 321 | + | /// | |
| 322 | + | /// The action's own params ride with whatever the screen gathered, which is | |
| 323 | + | /// what `absorb` is for: a control that names a value and a selection that | |
| 324 | + | /// names members both belong in one payload. | |
| 325 | + | fn send(action: &Action, extra: Params) -> Step { | |
| 326 | + | let Some(path) = action.destination.route() else { | |
| 327 | + | return Step::Open(action.destination.as_str().to_string()); | |
| 328 | + | }; | |
| 329 | + | let mut payload = extra; | |
| 330 | + | payload.absorb(action.params.clone()); | |
| 331 | + | Step::Call(Request { | |
| 332 | + | method: action.method, | |
| 333 | + | path: path.to_string(), | |
| 334 | + | captures: Params::new(), | |
| 335 | + | payload, | |
| 336 | + | carried: action.carried.clone(), | |
| 337 | + | }) | |
| 338 | + | } | |
| 339 | + | ||
| 340 | + | /// A read of a route, for a binding that names one. | |
| 341 | + | fn call(action: &Action) -> Step { | |
| 342 | + | Self::send(action, Params::new()) | |
| 343 | + | } | |
| 344 | + | } | |
| 345 | + | ||
| 346 | + | /// A key name as egui's key and modifiers. | |
| 347 | + | /// | |
| 348 | + | /// The same string comparison `Act::key` gets, resolved against this host's | |
| 349 | + | /// keyboard rather than the description's idea of one. A name this renderer | |
| 350 | + | /// cannot read is `None`, which is what a binding written for another host | |
| 351 | + | /// should do here. | |
| 352 | + | fn parse(key: &str) -> Option<(egui::Key, egui::Modifiers)> { | |
| 353 | + | let mut modifiers = egui::Modifiers::NONE; | |
| 354 | + | let mut base = None; | |
| 355 | + | for part in key.split('+') { | |
| 356 | + | let part = part.trim(); | |
| 357 | + | if part.is_empty() { | |
| 358 | + | return None; | |
| 359 | + | } | |
| 360 | + | match part.to_ascii_lowercase().as_str() { | |
| 361 | + | "ctrl" | "control" => modifiers.ctrl = true, | |
| 362 | + | "alt" | "option" => modifiers.alt = true, | |
| 363 | + | "shift" => modifiers.shift = true, | |
| 364 | + | "meta" | "cmd" | "super" => modifiers.command = true, | |
| 365 | + | _ if base.is_some() => return None, | |
| 366 | + | other => base = egui::Key::from_name(other).or_else(|| egui::Key::from_name(part)), | |
| 367 | + | } | |
| 368 | + | } | |
| 369 | + | base.map(|key| (key, modifiers)) | |
| 370 | + | } |
| @@ -1,0 +1,374 @@ | |||
| 1 | + | //! What egui draws from a description. | |
| 2 | + | //! | |
| 3 | + | //! Assertions are over what came back rather than over pixels: egui's test | |
| 4 | + | //! harness lays a real `Ui` out and answers real `Response`s, so what a renderer | |
| 5 | + | //! owes is that pressing a described control produces the described request. | |
| 6 | + | //! What it looks like is the palette's answer and changes with it. | |
| 7 | + | ||
| 8 | + | use egui::Color32; | |
| 9 | + | use makeover_immediate::Palette; | |
| 10 | + | use quasi_router::{ | |
| 11 | + | Act, Action, Address, Chrome, Field, Message, Method, Node, Outcome, RegionKind, Request, | |
| 12 | + | Response, Screen, Slot, layout, | |
| 13 | + | }; | |
| 14 | + | ||
| 15 | + | use crate::{Immediate, Runtime, Step, View}; | |
| 16 | + | ||
| 17 | + | fn palette() -> Palette { | |
| 18 | + | Palette { | |
| 19 | + | page: Color32::from_rgb(1, 1, 1), | |
| 20 | + | raised: Color32::from_rgb(2, 2, 2), | |
| 21 | + | overlay: Color32::from_rgb(3, 3, 3), | |
| 22 | + | well: Color32::from_rgb(4, 4, 4), | |
| 23 | + | sunken: Color32::from_rgb(5, 5, 5), | |
| 24 | + | bevel_light: Color32::WHITE, | |
| 25 | + | bevel_dark: Color32::BLACK, | |
| 26 | + | elevation: Color32::from_black_alpha(46), | |
| 27 | + | content: Color32::from_rgb(6, 6, 6), | |
| 28 | + | content_muted: Color32::from_rgb(7, 7, 7), | |
| 29 | + | action: Color32::from_rgb(8, 8, 8), | |
| 30 | + | danger: Color32::from_rgb(9, 9, 9), | |
| 31 | + | success: Color32::from_rgb(10, 10, 10), | |
| 32 | + | warning: Color32::from_rgb(11, 11, 11), | |
| 33 | + | info: Color32::from_rgb(12, 12, 12), | |
| 34 | + | } | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | fn renderer() -> Immediate { | |
| 38 | + | Immediate::new(palette()) | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | /// A screen with one region holding these nodes. | |
| 42 | + | fn screen_of(nodes: impl IntoIterator<Item = Node>) -> Screen { | |
| 43 | + | Screen::sidebar_content("Test").with( | |
| 44 | + | nodes | |
| 45 | + | .into_iter() | |
| 46 | + | .fold(Slot::new("main", RegionKind::Pane), Slot::with), | |
| 47 | + | ) | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | /// Draw a screen once, with nothing pressed. | |
| 51 | + | fn draw(screen: &Screen, view: &mut View) { | |
| 52 | + | let immediate = renderer(); | |
| 53 | + | egui::__run_test_ui(|ui| { | |
| 54 | + | immediate.screen(ui, screen, view); | |
| 55 | + | }); | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | #[test] | |
| 59 | + | fn every_described_node_draws_without_panicking() { | |
| 60 | + | // The walk is exhaustive over `Node`, so this is the assertion that the | |
| 61 | + | // exhaustiveness is real rather than a match that compiles: one of | |
| 62 | + | // everything, drawn. | |
| 63 | + | let mut view = View::new(); | |
| 64 | + | let screen = screen_of([ | |
| 65 | + | Node::page("Tasks"), | |
| 66 | + | Node::text("plain"), | |
| 67 | + | Node::rich("**bold** and `code`"), | |
| 68 | + | Node::Act(Act::new("Save", Action::post("/save"))), | |
| 69 | + | Node::Link { | |
| 70 | + | text: "Docs".to_owned(), | |
| 71 | + | action: Action::get("/docs"), | |
| 72 | + | }, | |
| 73 | + | Node::Figure(quasi_router::Figure::new("17", "Streak")), | |
| 74 | + | Node::Token(quasi_router::Tag::badge("beta")), | |
| 75 | + | Node::Meter(quasi_router::Meter::new(3, 6)), | |
| 76 | + | Node::Field(Box::new(Field::new( | |
| 77 | + | layout::FieldKind::Text, | |
| 78 | + | "title", | |
| 79 | + | "Title", | |
| 80 | + | ))), | |
| 81 | + | Node::list([quasi_router::Row::new("One")]), | |
| 82 | + | ]); | |
| 83 | + | draw(&screen, &mut view); | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | #[test] | |
| 87 | + | fn a_screen_with_no_press_asks_for_nothing() { | |
| 88 | + | // egui redraws continuously, so the ordinary frame is a user doing nothing. | |
| 89 | + | // A renderer answering a request per frame would call the router sixty | |
| 90 | + | // times a second. | |
| 91 | + | let immediate = renderer(); | |
| 92 | + | let screen = screen_of([Node::Act(Act::new("Save", Action::post("/save")))]); | |
| 93 | + | let mut view = View::new(); | |
| 94 | + | egui::__run_test_ui(|ui| { | |
| 95 | + | assert!(immediate.screen(ui, &screen, &mut view).is_none()); | |
| 96 | + | }); | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | #[test] | |
| 100 | + | fn what_is_typed_lives_in_the_view_and_not_in_the_description() { | |
| 101 | + | // The one thing egui does not hold for this renderer: a described field is | |
| 102 | + | // rebuilt every frame, so the buffer behind it has to outlive the frame. | |
| 103 | + | let mut view = View::new(); | |
| 104 | + | view.set("title", "hello"); | |
| 105 | + | assert_eq!(view.showing("title", Some("described")), "hello"); | |
| 106 | + | // Untouched reads the description; cleared does not. | |
| 107 | + | assert_eq!(view.showing("other", Some("described")), "described"); | |
| 108 | + | view.set("other", ""); | |
| 109 | + | assert_eq!(view.showing("other", Some("described")), ""); | |
| 110 | + | } | |
| 111 | + | ||
| 112 | + | #[test] | |
| 113 | + | fn a_form_submits_every_name_it_declared() { | |
| 114 | + | // A form that omits an untouched field is a form that cannot clear one. | |
| 115 | + | let mut view = View::new(); | |
| 116 | + | view.set("title", "typed"); | |
| 117 | + | let described = [("body".to_owned(), "offered".to_owned())] | |
| 118 | + | .into_iter() | |
| 119 | + | .collect(); | |
| 120 | + | let params = view.submission( | |
| 121 | + | &["title".to_owned(), "body".to_owned(), "empty".to_owned()], | |
| 122 | + | &described, | |
| 123 | + | ); | |
| 124 | + | assert_eq!(params.get("title"), Some("typed")); | |
| 125 | + | assert_eq!(params.get("body"), Some("offered")); | |
| 126 | + | assert_eq!(params.get("empty"), Some("")); | |
| 127 | + | } | |
| 128 | + | ||
| 129 | + | #[test] | |
| 130 | + | fn a_tick_is_the_views_and_the_description_only_seeds_it() { | |
| 131 | + | // After arrival the user's ticks are the truth, which is why seeding is | |
| 132 | + | // applied once rather than read on every draw. | |
| 133 | + | let mut row = quasi_router::Row::new("One"); | |
| 134 | + | row.selected = Some(true); | |
| 135 | + | row.value = Some("1".to_owned()); | |
| 136 | + | let screen = screen_of([Node::list([row])]); | |
| 137 | + | ||
| 138 | + | let mut view = View::new(); | |
| 139 | + | view.seed(&screen); | |
| 140 | + | assert!(view.is_ticked("1")); | |
| 141 | + | view.tick("1"); | |
| 142 | + | assert!( | |
| 143 | + | !view.is_ticked("1"), | |
| 144 | + | "the user untocked it and it stayed off" | |
| 145 | + | ); | |
| 146 | + | } | |
| 147 | + | ||
| 148 | + | #[test] | |
| 149 | + | fn an_overlay_is_not_a_place_and_dismissing_it_reveals_what_was_under_it() { | |
| 150 | + | let mut runtime = Runtime::new(screen_of([Node::text("underneath")])); | |
| 151 | + | // Two navigations, because history holds where you *were*: the first | |
| 152 | + | // records where we are and the second pushes it behind us. | |
| 153 | + | for path in ["/two", "/three"] { | |
| 154 | + | runtime.apply( | |
| 155 | + | &Request::get(path), | |
| 156 | + | Response { | |
| 157 | + | outcome: Outcome::Screen(screen_of([Node::text("a place")])), | |
| 158 | + | notice: None, | |
| 159 | + | address: None, | |
| 160 | + | invalidates: Vec::new(), | |
| 161 | + | }, | |
| 162 | + | ); | |
| 163 | + | } | |
| 164 | + | ||
| 165 | + | runtime.apply( | |
| 166 | + | &Request::get("/palette"), | |
| 167 | + | Response { | |
| 168 | + | outcome: Outcome::Over(screen_of([Node::text("palette")])), | |
| 169 | + | notice: None, | |
| 170 | + | address: None, | |
| 171 | + | invalidates: Vec::new(), | |
| 172 | + | }, | |
| 173 | + | ); | |
| 174 | + | assert!(runtime.overlaid()); | |
| 175 | + | assert_eq!(runtime.screen().title, "Test"); | |
| 176 | + | ||
| 177 | + | // Dismissing reveals rather than navigates, so history is untouched and | |
| 178 | + | // still has somewhere to go afterwards. | |
| 179 | + | assert!(matches!(runtime.back(), Step::Call(_))); | |
| 180 | + | } | |
| 181 | + | ||
| 182 | + | #[test] | |
| 183 | + | fn a_navigation_takes_the_overlay_with_it() { | |
| 184 | + | // Arriving somewhere new with a palette still floating over it is the state | |
| 185 | + | // nobody asked for. | |
| 186 | + | let mut runtime = Runtime::new(screen_of([Node::text("first")])); | |
| 187 | + | runtime.apply( | |
| 188 | + | &Request::get("/palette"), | |
| 189 | + | Response { | |
| 190 | + | outcome: Outcome::Over(screen_of([Node::text("palette")])), | |
| 191 | + | notice: None, | |
| 192 | + | address: None, | |
| 193 | + | invalidates: Vec::new(), | |
| 194 | + | }, | |
| 195 | + | ); | |
| 196 | + | assert!(runtime.overlaid()); | |
| 197 | + | runtime.apply( | |
| 198 | + | &Request::get("/two"), | |
| 199 | + | Response { | |
| 200 | + | outcome: Outcome::Screen(screen_of([Node::text("second")])), | |
| 201 | + | notice: None, | |
| 202 | + | address: None, | |
| 203 | + | invalidates: Vec::new(), | |
| 204 | + | }, | |
| 205 | + | ); | |
| 206 | + | assert!(!runtime.overlaid()); | |
| 207 | + | } | |
| 208 | + | ||
| 209 | + | #[test] | |
| 210 | + | fn a_write_is_not_a_place_and_a_read_of_a_screen_is() { | |
| 211 | + | let mut runtime = Runtime::new(screen_of([Node::text("first")])); | |
| 212 | + | for path in ["/two", "/three"] { | |
| 213 | + | runtime.apply( | |
| 214 | + | &Request::get(path), | |
| 215 | + | Response { | |
| 216 | + | outcome: Outcome::Screen(screen_of([Node::text("place")])), | |
| 217 | + | notice: None, | |
| 218 | + | address: None, | |
| 219 | + | invalidates: Vec::new(), | |
| 220 | + | }, | |
| 221 | + | ); | |
| 222 | + | } | |
| 223 | + | // A write answering a screen is not somewhere to come back to. | |
| 224 | + | runtime.apply( | |
| 225 | + | &Request::post("/save"), | |
| 226 | + | Response { | |
| 227 | + | outcome: Outcome::Screen(screen_of([Node::text("saved")])), | |
| 228 | + | notice: None, | |
| 229 | + | address: None, | |
| 230 | + | invalidates: Vec::new(), | |
| 231 | + | }, | |
| 232 | + | ); | |
| 233 | + | match runtime.back() { | |
| 234 | + | Step::Call(request) => assert_eq!(request.path, "/two"), | |
| 235 | + | other => panic!("expected the place behind, got {other:?}"), | |
| 236 | + | } | |
| 237 | + | } | |
| 238 | + | ||
| 239 | + | #[test] | |
| 240 | + | fn an_address_the_router_named_overrides_the_derivation() { | |
| 241 | + | let mut runtime = Runtime::new(screen_of([Node::text("first")])); | |
| 242 | + | runtime.apply( | |
| 243 | + | &Request::get("/one"), | |
| 244 | + | Response { | |
| 245 | + | outcome: Outcome::Screen(screen_of([Node::text("one")])), | |
| 246 | + | notice: None, | |
| 247 | + | address: None, | |
| 248 | + | invalidates: Vec::new(), | |
| 249 | + | }, | |
| 250 | + | ); | |
| 251 | + | // `Unchanged` says this read is not a place, so nothing is pushed behind it. | |
| 252 | + | runtime.apply( | |
| 253 | + | &Request::get("/transient"), | |
| 254 | + | Response { | |
| 255 | + | outcome: Outcome::Screen(screen_of([Node::text("transient")])), | |
| 256 | + | notice: None, | |
| 257 | + | address: Some(Address::Unchanged), | |
| 258 | + | invalidates: Vec::new(), | |
| 259 | + | }, | |
| 260 | + | ); | |
| 261 | + | assert!(matches!(runtime.back(), Step::Idle)); | |
| 262 | + | } | |
| 263 | + | ||
| 264 | + | #[test] | |
| 265 | + | fn a_fragment_naming_a_region_that_is_not_there_says_so() { | |
| 266 | + | // A terminal and a window can both say it, where a webview swallows it: | |
| 267 | + | // drawing nothing would look like a control that does nothing. | |
| 268 | + | let mut runtime = Runtime::new(screen_of([Node::text("first")])); | |
| 269 | + | runtime.apply( | |
| 270 | + | &Request::get("/x"), | |
| 271 | + | Response { | |
| 272 | + | outcome: Outcome::Fragment { | |
| 273 | + | region: "nowhere".to_owned(), | |
| 274 | + | node: Node::text("new"), | |
| 275 | + | }, | |
| 276 | + | notice: None, | |
| 277 | + | address: None, | |
| 278 | + | invalidates: Vec::new(), | |
| 279 | + | }, | |
| 280 | + | ); | |
| 281 | + | let said = runtime | |
| 282 | + | .screen() | |
| 283 | + | .notices | |
| 284 | + | .iter() | |
| 285 | + | .any(|node| matches!(node, Node::Notice { text, .. } if text.contains("nowhere"))); | |
| 286 | + | assert!(said, "the missing region was not reported"); | |
| 287 | + | } | |
| 288 | + | ||
| 289 | + | #[test] | |
| 290 | + | fn what_a_response_says_lands_on_the_screen_it_belongs_to() { | |
| 291 | + | let mut runtime = Runtime::new(screen_of([Node::text("first")])); | |
| 292 | + | runtime.apply( | |
| 293 | + | &Request::post("/save"), | |
| 294 | + | Response { | |
| 295 | + | outcome: Outcome::Screen(screen_of([Node::text("second")])), | |
| 296 | + | notice: Some(Message { | |
| 297 | + | kind: layout::Notice::Toast, | |
| 298 | + | tone: layout::Tone::Success, | |
| 299 | + | text: "Saved".to_owned(), | |
| 300 | + | undo: None, | |
| 301 | + | }), | |
| 302 | + | address: None, | |
| 303 | + | invalidates: Vec::new(), | |
| 304 | + | }, | |
| 305 | + | ); | |
| 306 | + | assert!( | |
| 307 | + | runtime | |
| 308 | + | .screen() | |
| 309 | + | .notices | |
| 310 | + | .iter() | |
| 311 | + | .any(|node| matches!(node, Node::Notice { text, .. } if text == "Saved")), | |
| 312 | + | "the notice did not arrive with the screen it belongs to" | |
| 313 | + | ); | |
| 314 | + | } | |
| 315 | + | ||
| 316 | + | #[test] | |
| 317 | + | fn an_external_destination_is_handed_back_to_the_host() { | |
| 318 | + | let mut runtime = Runtime::new(screen_of([Node::text("first")])); | |
| 319 | + | let step = runtime.answer(false); | |
| 320 | + | assert!( | |
| 321 | + | matches!(step, Step::Idle), | |
| 322 | + | "an unasked question does nothing" | |
| 323 | + | ); | |
| 324 | + | ||
| 325 | + | // The question a destructive control raises, answered both ways. | |
| 326 | + | let mut runtime = Runtime::new(screen_of([Node::Act( | |
| 327 | + | Act::new("Delete", Action::post("/delete")).confirm("Sure?"), | |
| 328 | + | )])); | |
| 329 | + | assert!(matches!(runtime.answer(true), Step::Idle)); | |
| 330 | + | } | |
| 331 | + | ||
| 332 | + | #[test] | |
| 333 | + | fn a_chrome_binding_this_renderer_cannot_read_is_ignored_rather_than_guessed() { | |
| 334 | + | // The same rule `Act::key` states: the vocabulary of keys is the host's, and | |
| 335 | + | // a name this one does not know never matches. | |
| 336 | + | let chrome = Chrome::new() | |
| 337 | + | .bind("ctrl+k", "Search", Action::get("/palette")) | |
| 338 | + | .bind("dpad-left", "Nope", Action::get("/nope")); | |
| 339 | + | let runtime = Runtime::new(screen_of([Node::text("x")])).with_chrome(chrome); | |
| 340 | + | // Nothing is pressed in a test context, so this asserts the parse rather | |
| 341 | + | // than the press: an unreadable name must not panic the frame. | |
| 342 | + | let immediate = renderer(); | |
| 343 | + | let mut runtime = runtime; | |
| 344 | + | egui::__run_test_ui(|ui| { | |
| 345 | + | assert!(matches!(runtime.show(ui, &immediate), Step::Idle)); | |
| 346 | + | }); | |
| 347 | + | } | |
| 348 | + | ||
| 349 | + | #[test] | |
| 350 | + | fn a_method_survives_the_trip_from_description_to_request() { | |
| 351 | + | // What a control carries is what the router is asked for. The regression | |
| 352 | + | // this guards is a renderer that turns every press into a GET. | |
| 353 | + | let immediate = renderer(); | |
| 354 | + | let screen = screen_of([Node::Act(Act::new("Delete", Action::post("/delete")))]); | |
| 355 | + | let mut view = View::new(); | |
| 356 | + | egui::__run_test_ui(|ui| { | |
| 357 | + | // Not pressed, so nothing fires; the assertion is that drawing a write | |
| 358 | + | // control does not itself produce a request. | |
| 359 | + | assert!(immediate.screen(ui, &screen, &mut view).is_none()); | |
| 360 | + | }); | |
| 361 | + | assert_eq!(Action::post("/delete").method, Method::Post); | |
| 362 | + | } | |
| 363 | + | ||
| 364 | + | #[test] | |
| 365 | + | fn a_selection_is_gathered_under_the_name_the_screen_gave_it() { | |
| 366 | + | // The commit half of a staged tick: the runtime reads the set the view is | |
| 367 | + | // holding and sends it with the call. | |
| 368 | + | let mut view = View::new(); | |
| 369 | + | view.tick("1"); | |
| 370 | + | view.tick("2"); | |
| 371 | + | let params = view.gathering(Node::TICKED); | |
| 372 | + | let sent: Vec<&str> = params.get_all(Node::TICKED).collect(); | |
| 373 | + | assert_eq!(sent, ["1", "2"]); | |
| 374 | + | } |
| @@ -1,0 +1,153 @@ | |||
| 1 | + | //! What the user has done to a screen since it arrived. | |
| 2 | + | //! | |
| 3 | + | //! Two of the five things `quasi-tui`'s `View` holds, and the other three are | |
| 4 | + | //! egui's. That is the whole difference between the two crates' state, and it is | |
| 5 | + | //! worth saying which is which so the next reader does not go looking for the | |
| 6 | + | //! missing ones: | |
| 7 | + | //! | |
| 8 | + | //! | Fact | terminal | here | | |
| 9 | + | //! |---|---|---| | |
| 10 | + | //! | what is typed | `View` | **`View`** | | |
| 11 | + | //! | what is ticked | `View` | **`View`** | | |
| 12 | + | //! | what has focus | `View` | egui's id stack | | |
| 13 | + | //! | how far a pane is scrolled | `View` | `egui::ScrollArea` | | |
| 14 | + | //! | where back goes | `Runtime` | [`Runtime`](crate::Runtime) | | |
| 15 | + | //! | |
| 16 | + | //! **Why typing is not egui's, when focus is.** egui holds widget state against | |
| 17 | + | //! an id, and a described field is rebuilt from the description every frame; its | |
| 18 | + | //! `TextEdit` needs a `&mut String` that outlives the frame, and the description | |
| 19 | + | //! deliberately does not carry the value. So the buffer is the app's, held here. | |
| 20 | + | //! That is the same conclusion `makeover-immediate`'s `Filling` reached one layer | |
| 21 | + | //! down and for the same reason. | |
| 22 | + | ||
| 23 | + | use std::collections::{BTreeMap, BTreeSet}; | |
| 24 | + | ||
| 25 | + | use quasi_router::{Node, Params, Screen}; | |
| 26 | + | ||
| 27 | + | /// What the user has done to a screen since it arrived. | |
| 28 | + | /// | |
| 29 | + | /// A host makes one beside the screen it is holding and keeps the two together. | |
| 30 | + | /// Empty is the honest starting state and it draws exactly what the description | |
| 31 | + | /// says. | |
| 32 | + | #[derive(Debug, Clone, Default, PartialEq, Eq)] | |
| 33 | + | pub struct View { | |
| 34 | + | /// What has been typed, by [`Field::name`](quasi_router::Field::name). | |
| 35 | + | /// | |
| 36 | + | /// Absent means untouched, which is different from present and empty: one | |
| 37 | + | /// draws the description's value and the other draws a box the user has | |
| 38 | + | /// cleared. | |
| 39 | + | edits: BTreeMap<String, String>, | |
| 40 | + | /// What has been ticked, by [`Row::value`](quasi_router::Row::value). | |
| 41 | + | ticked: BTreeSet<String>, | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | impl View { | |
| 45 | + | /// Nothing typed and nothing ticked. | |
| 46 | + | #[must_use] | |
| 47 | + | pub fn new() -> Self { | |
| 48 | + | Self::default() | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | /// What a field is showing: what was typed, or what the description offers. | |
| 52 | + | #[must_use] | |
| 53 | + | pub fn showing<'a>(&'a self, name: &str, described: Option<&'a str>) -> &'a str { | |
| 54 | + | self.edits | |
| 55 | + | .get(name) | |
| 56 | + | .map_or(described.unwrap_or_default(), String::as_str) | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | /// The buffer a text control writes through, seeded from the description. | |
| 60 | + | /// | |
| 61 | + | /// `&mut` because that is what an immediate-mode text control takes: there | |
| 62 | + | /// is no DOM to read the value back out of afterwards. Seeding on first | |
| 63 | + | /// touch rather than up front is what keeps "untouched" distinguishable | |
| 64 | + | /// from "cleared". | |
| 65 | + | pub fn buffer(&mut self, name: &str, described: Option<&str>) -> &mut String { | |
| 66 | + | self.edits | |
| 67 | + | .entry(name.to_owned()) | |
| 68 | + | .or_insert_with(|| described.unwrap_or_default().to_owned()) | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | /// What has been typed into a field, if anything has. | |
| 72 | + | #[must_use] | |
| 73 | + | pub fn edit(&self, name: &str) -> Option<&str> { | |
| 74 | + | self.edits.get(name).map(String::as_str) | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | /// Put a value in, as a host restoring one would. | |
| 78 | + | pub fn set(&mut self, name: impl Into<String>, value: impl Into<String>) { | |
| 79 | + | self.edits.insert(name.into(), value.into()); | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | /// Whether a row's value is in the screen's selection. | |
| 83 | + | #[must_use] | |
| 84 | + | pub fn is_ticked(&self, value: &str) -> bool { | |
| 85 | + | self.ticked.contains(value) | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | /// Add or remove a row's value from the selection. | |
| 89 | + | pub fn tick(&mut self, value: &str) { | |
| 90 | + | if !self.ticked.remove(value) { | |
| 91 | + | self.ticked.insert(value.to_owned()); | |
| 92 | + | } | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | /// Everything ticked, in a stable order. | |
| 96 | + | pub fn ticks(&self) -> impl Iterator<Item = &str> { | |
| 97 | + | self.ticked.iter().map(String::as_str) | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | /// The rows a new screen says are already ticked. | |
| 101 | + | /// | |
| 102 | + | /// Applied once on arrival rather than read on every draw: after this the | |
| 103 | + | /// user's ticks are the truth, and a description that kept overriding them | |
| 104 | + | /// would undo a tick the moment anything redrew. | |
| 105 | + | pub fn seed(&mut self, screen: &Screen) { | |
| 106 | + | for slot in &screen.slots { | |
| 107 | + | for node in &slot.body { | |
| 108 | + | if let Node::List { rows, .. } = node { | |
| 109 | + | for row in rows { | |
| 110 | + | if let (Some(true), Some(value)) = (row.selected, row.value.as_ref()) { | |
| 111 | + | self.ticked.insert(value.clone()); | |
| 112 | + | } | |
| 113 | + | } | |
| 114 | + | } | |
| 115 | + | } | |
| 116 | + | } | |
| 117 | + | } | |
| 118 | + | ||
| 119 | + | /// Forget everything, for a screen that has been replaced. | |
| 120 | + | pub fn reset(&mut self) { | |
| 121 | + | self.edits.clear(); | |
| 122 | + | self.ticked.clear(); | |
| 123 | + | } | |
| 124 | + | ||
| 125 | + | /// The values a form submits, by the names it declared. | |
| 126 | + | /// | |
| 127 | + | /// Every declared name is sent, including the ones nothing was typed into, | |
| 128 | + | /// because a form that omits an untouched field is a form that cannot clear | |
| 129 | + | /// one. What is sent for those is whatever the description offered. | |
| 130 | + | #[must_use] | |
| 131 | + | pub fn submission(&self, names: &[String], described: &BTreeMap<String, String>) -> Params { | |
| 132 | + | let mut params = Params::new(); | |
| 133 | + | for name in names { | |
| 134 | + | let value = self | |
| 135 | + | .edits | |
| 136 | + | .get(name) | |
| 137 | + | .or_else(|| described.get(name)) | |
| 138 | + | .map_or("", String::as_str); | |
| 139 | + | params = params.with(name.clone(), value.to_owned()); | |
| 140 | + | } | |
| 141 | + | params | |
| 142 | + | } | |
| 143 | + | ||
| 144 | + | /// What the screen's selection sends with an action taken over it. | |
| 145 | + | #[must_use] | |
| 146 | + | pub fn gathering(&self, under: &str) -> Params { | |
| 147 | + | let mut params = Params::new(); | |
| 148 | + | for value in self.ticks() { | |
| 149 | + | params = params.with(under.to_owned(), value.to_owned()); | |
| 150 | + | } | |
| 151 | + | params | |
| 152 | + | } | |
| 153 | + | } |