Skip to main content

max / quasi

Add quasi-immediate: the egui renderer for a described screen The third renderer, and the one the stack was missing. quasi-webview and quasi-tui have consumed a Screen since the beginning and nothing did in egui, so an egui app describing a screen had nowhere to send it. What looked like the egui renderer was makeover-immediate, which draws described nodes and takes no quasi-router dependency at all: it is the peer of makeover-webview and makeover-tui, one layer below this. Immediate mode makes it smaller than the terminal's rather than larger. quasi-tui's View holds five things a browser provides quietly; egui provides three of them, so reach, focus and scroll are egui's and there is no focus.rs here. What is left is what is typed and what is ticked, because a described field is rebuilt every frame and its buffer has to outlive one, which is the same conclusion makeover-immediate's Filling reached a layer down. The node walk is exhaustive with no catch-all, because Node carries no non_exhaustive and that is deliberate upstream: a node added to the vocabulary should stop every renderer compiling until each decides what it looks like. Same argument Outcome documents. Chrome and Outcome::Over are both answered -- bindings read off the context before the frame, so a screen cannot capture the palette's key, and an overlay is an Area at Order::Foreground over a layer stack that leaves history alone. Two things are deliberately not drawn and say so rather than failing quietly: a described table, which wants makeover_immediate::table's cell closures wired through, and Node::Rich, which takes the plain rendering until egui has a markdown widget worth adopting.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-13 19:02 UTC
Signed with PGP, not checked
Commit: 34f5145445f0351eb78984f97030918594bacf16
Parent: 4f805f3
9 files changed, +2038 insertions, -0 deletions
M Cargo.lock +341
@@ -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
M Cargo.toml +1
@@ -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(&region, 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 + }