Bring vidya in
egui is the only backend anyone wants now, so the reason libvidya's symbols were kept interchangeable — a raylib build, a cimgui build, swapped by search path alone — was paying for a choice nobody is going to make. With that gone there is no argument left for vidya being somewhere else. Raw, without history: crates/vidya-core is the egui layer, crates/jolt-vidya the C ABI over it, jolt/glimmer-vidya the jolt side. The raylib and cimgui backends, the buck2 vendoring and the Android host did not come, and neither did 6.5G of build output. Both objects now come out of one target directory, so a consumer points LD_LIBRARY_PATH at one place and still links only what it names. opt-level 2 moves to the workspace root. vidya-ffi set it when it was its own workspace, and a member's profile is ignored — the tree walk and the texture uploads are hot every frame. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
cfd3e36 parent: 90f8b89 modified
Cargo.lock +1481 -54 | @@ -2,6 +2,22 @@ | ||
| 2 | 2 | # It is not intended for manual editing. |
| 3 | 3 | version = 4 |
| 4 | 4 | |
| 5 | +[[package]] | |
| 6 | +name = "ab_glyph" | |
| 7 | +version = "0.2.32" | |
| 8 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 9 | +checksum = "01c0457472c38ea5bd1c3b5ada5e368271cb550be7a4ca4a0b4634e9913f6cc2" | |
| 10 | +dependencies = [ | |
| 11 | + "ab_glyph_rasterizer", | |
| 12 | + "owned_ttf_parser", | |
| 13 | +] | |
| 14 | + | |
| 15 | +[[package]] | |
| 16 | +name = "ab_glyph_rasterizer" | |
| 17 | +version = "0.1.10" | |
| 18 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 19 | +checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" | |
| 20 | + | |
| 5 | 21 | [[package]] |
| 6 | 22 | name = "addr2line" |
| 7 | 23 | version = "0.25.1" |
| @@ -52,6 +68,19 @@ dependencies = [ | ||
| 52 | 68 | "subtle", |
| 53 | 69 | ] |
| 54 | 70 | |
| 71 | +[[package]] | |
| 72 | +name = "ahash" | |
| 73 | +version = "0.8.12" | |
| 74 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 75 | +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" | |
| 76 | +dependencies = [ | |
| 77 | + "cfg-if", | |
| 78 | + "getrandom 0.3.4", | |
| 79 | + "once_cell", | |
| 80 | + "version_check", | |
| 81 | + "zerocopy", | |
| 82 | +] | |
| 83 | + | |
| 55 | 84 | [[package]] |
| 56 | 85 | name = "aho-corasick" |
| 57 | 86 | version = "1.1.4" |
| @@ -89,6 +118,31 @@ dependencies = [ | ||
| 89 | 118 | "pkg-config", |
| 90 | 119 | ] |
| 91 | 120 | |
| 121 | +[[package]] | |
| 122 | +name = "android-activity" | |
| 123 | +version = "0.6.1" | |
| 124 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 125 | +checksum = "0f2a1bb052857d5dd49572219344a7332b31b76405648eabac5bc68978251bcd" | |
| 126 | +dependencies = [ | |
| 127 | + "android-properties", | |
| 128 | + "bitflags 2.13.1", | |
| 129 | + "cc", | |
| 130 | + "jni 0.22.4", | |
| 131 | + "libc", | |
| 132 | + "log", | |
| 133 | + "ndk", | |
| 134 | + "ndk-context", | |
| 135 | + "ndk-sys", | |
| 136 | + "num_enum", | |
| 137 | + "thiserror 2.0.19", | |
| 138 | +] | |
| 139 | + | |
| 140 | +[[package]] | |
| 141 | +name = "android-properties" | |
| 142 | +version = "0.2.2" | |
| 143 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 144 | +checksum = "fc7eb209b1518d6bb87b283c20095f5228ecda460da70b44f0802523dea6da04" | |
| 145 | + | |
| 92 | 146 | [[package]] |
| 93 | 147 | name = "android_system_properties" |
| 94 | 148 | version = "0.1.5" |
| @@ -188,6 +242,35 @@ version = "1.0.104" | ||
| 188 | 242 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 189 | 243 | checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" |
| 190 | 244 | |
| 245 | +[[package]] | |
| 246 | +name = "arbitrary" | |
| 247 | +version = "1.4.2" | |
| 248 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 249 | +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" | |
| 250 | +dependencies = [ | |
| 251 | + "derive_arbitrary", | |
| 252 | +] | |
| 253 | + | |
| 254 | +[[package]] | |
| 255 | +name = "arboard" | |
| 256 | +version = "3.6.1" | |
| 257 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 258 | +checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf" | |
| 259 | +dependencies = [ | |
| 260 | + "clipboard-win", | |
| 261 | + "image", | |
| 262 | + "log", | |
| 263 | + "objc2 0.6.4", | |
| 264 | + "objc2-app-kit 0.3.2", | |
| 265 | + "objc2-core-foundation", | |
| 266 | + "objc2-core-graphics", | |
| 267 | + "objc2-foundation 0.3.2", | |
| 268 | + "parking_lot", | |
| 269 | + "percent-encoding", | |
| 270 | + "windows-sys 0.59.0", | |
| 271 | + "x11rb", | |
| 272 | +] | |
| 273 | + | |
| 191 | 274 | [[package]] |
| 192 | 275 | name = "archery" |
| 193 | 276 | version = "1.2.2" |
| @@ -209,6 +292,12 @@ version = "0.7.8" | ||
| 209 | 292 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 210 | 293 | checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" |
| 211 | 294 | |
| 295 | +[[package]] | |
| 296 | +name = "as-raw-xcb-connection" | |
| 297 | +version = "1.0.1" | |
| 298 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 299 | +checksum = "175571dd1d178ced59193a6fc02dde1b972eb0bc56c892cde9beeceac5bf0f6b" | |
| 300 | + | |
| 212 | 301 | [[package]] |
| 213 | 302 | name = "asn1-rs" |
| 214 | 303 | version = "0.7.2" |
| @@ -385,7 +474,7 @@ dependencies = [ | ||
| 385 | 474 | "addr2line", |
| 386 | 475 | "cfg-if", |
| 387 | 476 | "libc", |
| 388 | - "miniz_oxide", | |
| 477 | + "miniz_oxide 0.8.9", | |
| 389 | 478 | "object", |
| 390 | 479 | "rustc-demangle", |
| 391 | 480 | "windows-link", |
| @@ -501,13 +590,22 @@ dependencies = [ | ||
| 501 | 590 | "hybrid-array", |
| 502 | 591 | ] |
| 503 | 592 | |
| 593 | +[[package]] | |
| 594 | +name = "block2" | |
| 595 | +version = "0.5.1" | |
| 596 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 597 | +checksum = "2c132eebf10f5cad5289222520a4a058514204aed6d791f1cf4fe8088b82d15f" | |
| 598 | +dependencies = [ | |
| 599 | + "objc2 0.5.2", | |
| 600 | +] | |
| 601 | + | |
| 504 | 602 | [[package]] |
| 505 | 603 | name = "block2" |
| 506 | 604 | version = "0.6.2" |
| 507 | 605 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 508 | 606 | checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" |
| 509 | 607 | dependencies = [ |
| 510 | - "objc2", | |
| 608 | + "objc2 0.6.4", | |
| 511 | 609 | ] |
| 512 | 610 | |
| 513 | 611 | [[package]] |
| @@ -575,6 +673,57 @@ dependencies = [ | ||
| 575 | 673 | "serde", |
| 576 | 674 | ] |
| 577 | 675 | |
| 676 | +[[package]] | |
| 677 | +name = "calloop" | |
| 678 | +version = "0.13.0" | |
| 679 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 680 | +checksum = "b99da2f8558ca23c71f4fd15dc57c906239752dd27ff3c00a1d56b685b7cbfec" | |
| 681 | +dependencies = [ | |
| 682 | + "bitflags 2.13.1", | |
| 683 | + "log", | |
| 684 | + "polling", | |
| 685 | + "rustix 0.38.44", | |
| 686 | + "slab", | |
| 687 | + "thiserror 1.0.69", | |
| 688 | +] | |
| 689 | + | |
| 690 | +[[package]] | |
| 691 | +name = "calloop" | |
| 692 | +version = "0.14.4" | |
| 693 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 694 | +checksum = "4dbf9978365bac10f54d1d4b04f7ce4427e51f71d61f2fe15e3fed5166474df7" | |
| 695 | +dependencies = [ | |
| 696 | + "bitflags 2.13.1", | |
| 697 | + "polling", | |
| 698 | + "rustix 1.1.4", | |
| 699 | + "slab", | |
| 700 | + "tracing", | |
| 701 | +] | |
| 702 | + | |
| 703 | +[[package]] | |
| 704 | +name = "calloop-wayland-source" | |
| 705 | +version = "0.3.0" | |
| 706 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 707 | +checksum = "95a66a987056935f7efce4ab5668920b5d0dac4a7c99991a67395f13702ddd20" | |
| 708 | +dependencies = [ | |
| 709 | + "calloop 0.13.0", | |
| 710 | + "rustix 0.38.44", | |
| 711 | + "wayland-backend", | |
| 712 | + "wayland-client", | |
| 713 | +] | |
| 714 | + | |
| 715 | +[[package]] | |
| 716 | +name = "calloop-wayland-source" | |
| 717 | +version = "0.4.1" | |
| 718 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 719 | +checksum = "138efcf0940a02ebf0cc8d1eff41a1682a46b431630f4c52450d6265876021fa" | |
| 720 | +dependencies = [ | |
| 721 | + "calloop 0.14.4", | |
| 722 | + "rustix 1.1.4", | |
| 723 | + "wayland-backend", | |
| 724 | + "wayland-client", | |
| 725 | +] | |
| 726 | + | |
| 578 | 727 | [[package]] |
| 579 | 728 | name = "cc" |
| 580 | 729 | version = "1.4.0" |
| @@ -630,6 +779,15 @@ version = "0.2.2" | ||
| 630 | 779 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 631 | 780 | checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" |
| 632 | 781 | |
| 782 | +[[package]] | |
| 783 | +name = "cgl" | |
| 784 | +version = "0.3.2" | |
| 785 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 786 | +checksum = "0ced0551234e87afee12411d535648dd89d2e7f34c78b753395567aff3d447ff" | |
| 787 | +dependencies = [ | |
| 788 | + "libc", | |
| 789 | +] | |
| 790 | + | |
| 633 | 791 | [[package]] |
| 634 | 792 | name = "chacha20" |
| 635 | 793 | version = "0.10.1" |
| @@ -714,6 +872,15 @@ version = "0.7.7" | ||
| 714 | 872 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 715 | 873 | checksum = "c3e64b0cc0439b12df2fa678eae89a1c56a529fd067a9115f7827f1fffd22b32" |
| 716 | 874 | |
| 875 | +[[package]] | |
| 876 | +name = "clipboard-win" | |
| 877 | +version = "5.4.1" | |
| 878 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 879 | +checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4" | |
| 880 | +dependencies = [ | |
| 881 | + "error-code", | |
| 882 | +] | |
| 883 | + | |
| 717 | 884 | [[package]] |
| 718 | 885 | name = "cmake" |
| 719 | 886 | version = "0.1.58" |
| @@ -766,6 +933,15 @@ dependencies = [ | ||
| 766 | 933 | "memchr", |
| 767 | 934 | ] |
| 768 | 935 | |
| 936 | +[[package]] | |
| 937 | +name = "concurrent-queue" | |
| 938 | +version = "2.5.0" | |
| 939 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 940 | +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" | |
| 941 | +dependencies = [ | |
| 942 | + "crossbeam-utils", | |
| 943 | +] | |
| 944 | + | |
| 769 | 945 | [[package]] |
| 770 | 946 | name = "conducer" |
| 771 | 947 | version = "0.3.1" |
| @@ -817,6 +993,16 @@ dependencies = [ | ||
| 817 | 993 | "tracing", |
| 818 | 994 | ] |
| 819 | 995 | |
| 996 | +[[package]] | |
| 997 | +name = "core-foundation" | |
| 998 | +version = "0.9.4" | |
| 999 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1000 | +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" | |
| 1001 | +dependencies = [ | |
| 1002 | + "core-foundation-sys", | |
| 1003 | + "libc", | |
| 1004 | +] | |
| 1005 | + | |
| 820 | 1006 | [[package]] |
| 821 | 1007 | name = "core-foundation" |
| 822 | 1008 | version = "0.10.1" |
| @@ -833,6 +1019,30 @@ version = "0.8.7" | ||
| 833 | 1019 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 834 | 1020 | checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" |
| 835 | 1021 | |
| 1022 | +[[package]] | |
| 1023 | +name = "core-graphics" | |
| 1024 | +version = "0.23.2" | |
| 1025 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1026 | +checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081" | |
| 1027 | +dependencies = [ | |
| 1028 | + "bitflags 1.3.2", | |
| 1029 | + "core-foundation 0.9.4", | |
| 1030 | + "core-graphics-types", | |
| 1031 | + "foreign-types", | |
| 1032 | + "libc", | |
| 1033 | +] | |
| 1034 | + | |
| 1035 | +[[package]] | |
| 1036 | +name = "core-graphics-types" | |
| 1037 | +version = "0.1.3" | |
| 1038 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1039 | +checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf" | |
| 1040 | +dependencies = [ | |
| 1041 | + "bitflags 1.3.2", | |
| 1042 | + "core-foundation 0.9.4", | |
| 1043 | + "libc", | |
| 1044 | +] | |
| 1045 | + | |
| 836 | 1046 | [[package]] |
| 837 | 1047 | name = "core_affinity" |
| 838 | 1048 | version = "0.8.3" |
| @@ -873,13 +1083,13 @@ dependencies = [ | ||
| 873 | 1083 | "ndk-context", |
| 874 | 1084 | "num-derive", |
| 875 | 1085 | "num-traits", |
| 876 | - "objc2", | |
| 1086 | + "objc2 0.6.4", | |
| 877 | 1087 | "objc2-audio-toolbox", |
| 878 | 1088 | "objc2-avf-audio", |
| 879 | 1089 | "objc2-core-audio", |
| 880 | 1090 | "objc2-core-audio-types", |
| 881 | 1091 | "objc2-core-foundation", |
| 882 | - "objc2-foundation", | |
| 1092 | + "objc2-foundation 0.3.2", | |
| 883 | 1093 | "pipewire", |
| 884 | 1094 | "wasm-bindgen", |
| 885 | 1095 | "wasm-bindgen-futures", |
| @@ -905,6 +1115,15 @@ dependencies = [ | ||
| 905 | 1115 | "libc", |
| 906 | 1116 | ] |
| 907 | 1117 | |
| 1118 | +[[package]] | |
| 1119 | +name = "crc32fast" | |
| 1120 | +version = "1.5.1" | |
| 1121 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1122 | +checksum = "8498c871161e1742aaa9d52551b2d6ebdd4c3d45a3be423e3728f33b955be550" | |
| 1123 | +dependencies = [ | |
| 1124 | + "cfg-if", | |
| 1125 | +] | |
| 1126 | + | |
| 908 | 1127 | [[package]] |
| 909 | 1128 | name = "critical-section" |
| 910 | 1129 | version = "1.2.0" |
| @@ -988,6 +1207,12 @@ dependencies = [ | ||
| 988 | 1207 | "cmov", |
| 989 | 1208 | ] |
| 990 | 1209 | |
| 1210 | +[[package]] | |
| 1211 | +name = "cursor-icon" | |
| 1212 | +version = "1.2.0" | |
| 1213 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1214 | +checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" | |
| 1215 | + | |
| 991 | 1216 | [[package]] |
| 992 | 1217 | name = "curve25519-dalek" |
| 993 | 1218 | version = "5.0.0-pre.1" |
| @@ -1163,6 +1388,17 @@ dependencies = [ | ||
| 1163 | 1388 | "serde_core", |
| 1164 | 1389 | ] |
| 1165 | 1390 | |
| 1391 | +[[package]] | |
| 1392 | +name = "derive_arbitrary" | |
| 1393 | +version = "1.4.2" | |
| 1394 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1395 | +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" | |
| 1396 | +dependencies = [ | |
| 1397 | + "proc-macro2", | |
| 1398 | + "quote", | |
| 1399 | + "syn 2.0.119", | |
| 1400 | +] | |
| 1401 | + | |
| 1166 | 1402 | [[package]] |
| 1167 | 1403 | name = "derive_builder" |
| 1168 | 1404 | version = "0.20.2" |
| @@ -1244,6 +1480,12 @@ dependencies = [ | ||
| 1244 | 1480 | "crypto-common 0.2.2", |
| 1245 | 1481 | ] |
| 1246 | 1482 | |
| 1483 | +[[package]] | |
| 1484 | +name = "dispatch" | |
| 1485 | +version = "0.2.0" | |
| 1486 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1487 | +checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b" | |
| 1488 | + | |
| 1247 | 1489 | [[package]] |
| 1248 | 1490 | name = "dispatch2" |
| 1249 | 1491 | version = "0.3.1" |
| @@ -1251,9 +1493,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1251 | 1493 | checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" |
| 1252 | 1494 | dependencies = [ |
| 1253 | 1495 | "bitflags 2.13.1", |
| 1254 | - "block2", | |
| 1496 | + "block2 0.6.2", | |
| 1255 | 1497 | "libc", |
| 1256 | - "objc2", | |
| 1498 | + "objc2 0.6.4", | |
| 1257 | 1499 | ] |
| 1258 | 1500 | |
| 1259 | 1501 | [[package]] |
| @@ -1267,6 +1509,15 @@ dependencies = [ | ||
| 1267 | 1509 | "syn 3.0.3", |
| 1268 | 1510 | ] |
| 1269 | 1511 | |
| 1512 | +[[package]] | |
| 1513 | +name = "dlib" | |
| 1514 | +version = "0.5.3" | |
| 1515 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1516 | +checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a" | |
| 1517 | +dependencies = [ | |
| 1518 | + "libloading", | |
| 1519 | +] | |
| 1520 | + | |
| 1270 | 1521 | [[package]] |
| 1271 | 1522 | name = "dlopen2" |
| 1272 | 1523 | version = "0.5.0" |
| @@ -1287,6 +1538,18 @@ dependencies = [ | ||
| 1287 | 1538 | "litrs", |
| 1288 | 1539 | ] |
| 1289 | 1540 | |
| 1541 | +[[package]] | |
| 1542 | +name = "downcast-rs" | |
| 1543 | +version = "1.2.1" | |
| 1544 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1545 | +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" | |
| 1546 | + | |
| 1547 | +[[package]] | |
| 1548 | +name = "dpi" | |
| 1549 | +version = "0.1.2" | |
| 1550 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1551 | +checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" | |
| 1552 | + | |
| 1290 | 1553 | [[package]] |
| 1291 | 1554 | name = "dunce" |
| 1292 | 1555 | version = "1.0.5" |
| @@ -1299,6 +1562,16 @@ version = "1.0.20" | ||
| 1299 | 1562 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 1300 | 1563 | checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" |
| 1301 | 1564 | |
| 1565 | +[[package]] | |
| 1566 | +name = "ecolor" | |
| 1567 | +version = "0.31.1" | |
| 1568 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1569 | +checksum = "bc4feb366740ded31a004a0e4452fbf84e80ef432ecf8314c485210229672fd1" | |
| 1570 | +dependencies = [ | |
| 1571 | + "bytemuck", | |
| 1572 | + "emath", | |
| 1573 | +] | |
| 1574 | + | |
| 1302 | 1575 | [[package]] |
| 1303 | 1576 | name = "ed25519" |
| 1304 | 1577 | version = "3.0.0-rc.4" |
| @@ -1326,12 +1599,72 @@ dependencies = [ | ||
| 1326 | 1599 | "zeroize", |
| 1327 | 1600 | ] |
| 1328 | 1601 | |
| 1602 | +[[package]] | |
| 1603 | +name = "egui" | |
| 1604 | +version = "0.31.1" | |
| 1605 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1606 | +checksum = "25dd34cec49ab55d85ebf70139cb1ccd29c977ef6b6ba4fe85489d6877ee9ef3" | |
| 1607 | +dependencies = [ | |
| 1608 | + "ahash", | |
| 1609 | + "bitflags 2.13.1", | |
| 1610 | + "emath", | |
| 1611 | + "epaint", | |
| 1612 | + "log", | |
| 1613 | + "nohash-hasher", | |
| 1614 | + "profiling", | |
| 1615 | +] | |
| 1616 | + | |
| 1617 | +[[package]] | |
| 1618 | +name = "egui-winit" | |
| 1619 | +version = "0.31.1" | |
| 1620 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1621 | +checksum = "7d9dfbb78fe4eb9c3a39ad528b90ee5915c252e77bbab9d4ebc576541ab67e13" | |
| 1622 | +dependencies = [ | |
| 1623 | + "ahash", | |
| 1624 | + "arboard", | |
| 1625 | + "bytemuck", | |
| 1626 | + "egui", | |
| 1627 | + "log", | |
| 1628 | + "profiling", | |
| 1629 | + "raw-window-handle", | |
| 1630 | + "smithay-clipboard", | |
| 1631 | + "web-time", | |
| 1632 | + "webbrowser", | |
| 1633 | + "winit", | |
| 1634 | +] | |
| 1635 | + | |
| 1636 | +[[package]] | |
| 1637 | +name = "egui_glow" | |
| 1638 | +version = "0.31.1" | |
| 1639 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1640 | +checksum = "910906e3f042ea6d2378ec12a6fd07698e14ddae68aed2d819ffe944a73aab9e" | |
| 1641 | +dependencies = [ | |
| 1642 | + "ahash", | |
| 1643 | + "bytemuck", | |
| 1644 | + "egui", | |
| 1645 | + "glow", | |
| 1646 | + "log", | |
| 1647 | + "memoffset", | |
| 1648 | + "profiling", | |
| 1649 | + "wasm-bindgen", | |
| 1650 | + "web-sys", | |
| 1651 | +] | |
| 1652 | + | |
| 1329 | 1653 | [[package]] |
| 1330 | 1654 | name = "either" |
| 1331 | 1655 | version = "1.17.0" |
| 1332 | 1656 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 1333 | 1657 | checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d" |
| 1334 | 1658 | |
| 1659 | +[[package]] | |
| 1660 | +name = "emath" | |
| 1661 | +version = "0.31.1" | |
| 1662 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1663 | +checksum = "9e4cadcff7a5353ba72b7fea76bf2122b5ebdbc68e8155aa56dfdea90083fe1b" | |
| 1664 | +dependencies = [ | |
| 1665 | + "bytemuck", | |
| 1666 | +] | |
| 1667 | + | |
| 1335 | 1668 | [[package]] |
| 1336 | 1669 | name = "embedded-io" |
| 1337 | 1670 | version = "0.4.0" |
| @@ -1410,6 +1743,30 @@ dependencies = [ | ||
| 1410 | 1743 | "log", |
| 1411 | 1744 | ] |
| 1412 | 1745 | |
| 1746 | +[[package]] | |
| 1747 | +name = "epaint" | |
| 1748 | +version = "0.31.1" | |
| 1749 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1750 | +checksum = "41fcc0f5a7c613afd2dee5e4b30c3e6acafb8ad6f0edb06068811f708a67c562" | |
| 1751 | +dependencies = [ | |
| 1752 | + "ab_glyph", | |
| 1753 | + "ahash", | |
| 1754 | + "bytemuck", | |
| 1755 | + "ecolor", | |
| 1756 | + "emath", | |
| 1757 | + "epaint_default_fonts", | |
| 1758 | + "log", | |
| 1759 | + "nohash-hasher", | |
| 1760 | + "parking_lot", | |
| 1761 | + "profiling", | |
| 1762 | +] | |
| 1763 | + | |
| 1764 | +[[package]] | |
| 1765 | +name = "epaint_default_fonts" | |
| 1766 | +version = "0.31.1" | |
| 1767 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1768 | +checksum = "fc7e7a64c02cf7a5b51e745a9e45f60660a286f151c238b9d397b3e923f5082f" | |
| 1769 | + | |
| 1413 | 1770 | [[package]] |
| 1414 | 1771 | name = "equivalent" |
| 1415 | 1772 | version = "1.0.2" |
| @@ -1426,6 +1783,12 @@ dependencies = [ | ||
| 1426 | 1783 | "windows-sys 0.61.2", |
| 1427 | 1784 | ] |
| 1428 | 1785 | |
| 1786 | +[[package]] | |
| 1787 | +name = "error-code" | |
| 1788 | +version = "3.4.0" | |
| 1789 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1790 | +checksum = "0b5343afd4a8365a643ac588dab4cf234a190c7f6c88c9f6dd6ffe00837661b7" | |
| 1791 | + | |
| 1429 | 1792 | [[package]] |
| 1430 | 1793 | name = "erydanos" |
| 1431 | 1794 | version = "0.2.18" |
| @@ -1465,6 +1828,21 @@ version = "2.5.0" | ||
| 1465 | 1828 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 1466 | 1829 | checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" |
| 1467 | 1830 | |
| 1831 | +[[package]] | |
| 1832 | +name = "fax" | |
| 1833 | +version = "0.2.7" | |
| 1834 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1835 | +checksum = "caf1079563223d5d59d83c85886a56e586cfd5c1a26292e971a0fa266531ac5a" | |
| 1836 | + | |
| 1837 | +[[package]] | |
| 1838 | +name = "fdeflate" | |
| 1839 | +version = "0.3.7" | |
| 1840 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1841 | +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" | |
| 1842 | +dependencies = [ | |
| 1843 | + "simd-adler32", | |
| 1844 | +] | |
| 1845 | + | |
| 1468 | 1846 | [[package]] |
| 1469 | 1847 | name = "fiat-crypto" |
| 1470 | 1848 | version = "0.3.0" |
| @@ -1501,6 +1879,17 @@ version = "0.5.7" | ||
| 1501 | 1879 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 1502 | 1880 | checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" |
| 1503 | 1881 | |
| 1882 | +[[package]] | |
| 1883 | +name = "flate2" | |
| 1884 | +version = "1.1.10" | |
| 1885 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1886 | +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" | |
| 1887 | +dependencies = [ | |
| 1888 | + "crc32fast", | |
| 1889 | + "miniz_oxide 0.9.1", | |
| 1890 | + "zlib-rs", | |
| 1891 | +] | |
| 1892 | + | |
| 1504 | 1893 | [[package]] |
| 1505 | 1894 | name = "fnv" |
| 1506 | 1895 | version = "1.0.7" |
| @@ -1513,6 +1902,33 @@ version = "0.2.0" | ||
| 1513 | 1902 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 1514 | 1903 | checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" |
| 1515 | 1904 | |
| 1905 | +[[package]] | |
| 1906 | +name = "foreign-types" | |
| 1907 | +version = "0.5.0" | |
| 1908 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1909 | +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" | |
| 1910 | +dependencies = [ | |
| 1911 | + "foreign-types-macros", | |
| 1912 | + "foreign-types-shared", | |
| 1913 | +] | |
| 1914 | + | |
| 1915 | +[[package]] | |
| 1916 | +name = "foreign-types-macros" | |
| 1917 | +version = "0.2.4" | |
| 1918 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1919 | +checksum = "ea5190182e6915eb873ddbc16e23b711b6eb1f9c00a0d0a3a91b5f6228475225" | |
| 1920 | +dependencies = [ | |
| 1921 | + "proc-macro2", | |
| 1922 | + "quote", | |
| 1923 | + "syn 3.0.3", | |
| 1924 | +] | |
| 1925 | + | |
| 1926 | +[[package]] | |
| 1927 | +name = "foreign-types-shared" | |
| 1928 | +version = "0.3.1" | |
| 1929 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1930 | +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" | |
| 1931 | + | |
| 1516 | 1932 | [[package]] |
| 1517 | 1933 | name = "form_urlencoded" |
| 1518 | 1934 | version = "1.2.2" |
| @@ -1680,6 +2096,16 @@ dependencies = [ | ||
| 1680 | 2096 | "version_check", |
| 1681 | 2097 | ] |
| 1682 | 2098 | |
| 2099 | +[[package]] | |
| 2100 | +name = "gethostname" | |
| 2101 | +version = "1.1.0" | |
| 2102 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2103 | +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" | |
| 2104 | +dependencies = [ | |
| 2105 | + "rustix 1.1.4", | |
| 2106 | + "windows-link", | |
| 2107 | +] | |
| 2108 | + | |
| 1683 | 2109 | [[package]] |
| 1684 | 2110 | name = "getrandom" |
| 1685 | 2111 | version = "0.2.17" |
| @@ -1737,6 +2163,17 @@ version = "0.32.3" | ||
| 1737 | 2163 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 1738 | 2164 | checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" |
| 1739 | 2165 | |
| 2166 | +[[package]] | |
| 2167 | +name = "gl_generator" | |
| 2168 | +version = "0.14.0" | |
| 2169 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2170 | +checksum = "1a95dfc23a2b4a9a2f5ab41d194f8bfda3cabec42af4e39f08c339eb2a0c124d" | |
| 2171 | +dependencies = [ | |
| 2172 | + "khronos_api", | |
| 2173 | + "log", | |
| 2174 | + "xml-rs", | |
| 2175 | +] | |
| 2176 | + | |
| 1740 | 2177 | [[package]] |
| 1741 | 2178 | name = "glob" |
| 1742 | 2179 | version = "0.3.4" |
| @@ -1755,6 +2192,84 @@ dependencies = [ | ||
| 1755 | 2192 | "wasm-bindgen", |
| 1756 | 2193 | ] |
| 1757 | 2194 | |
| 2195 | +[[package]] | |
| 2196 | +name = "glow" | |
| 2197 | +version = "0.16.0" | |
| 2198 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2199 | +checksum = "c5e5ea60d70410161c8bf5da3fdfeaa1c72ed2c15f8bbb9d19fe3a4fad085f08" | |
| 2200 | +dependencies = [ | |
| 2201 | + "js-sys", | |
| 2202 | + "slotmap", | |
| 2203 | + "wasm-bindgen", | |
| 2204 | + "web-sys", | |
| 2205 | +] | |
| 2206 | + | |
| 2207 | +[[package]] | |
| 2208 | +name = "glutin" | |
| 2209 | +version = "0.32.3" | |
| 2210 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2211 | +checksum = "12124de845cacfebedff80e877bb37b5b75c34c5a4c89e47e1cdd67fb6041325" | |
| 2212 | +dependencies = [ | |
| 2213 | + "bitflags 2.13.1", | |
| 2214 | + "cfg_aliases 0.2.2", | |
| 2215 | + "cgl", | |
| 2216 | + "dispatch2", | |
| 2217 | + "glutin_egl_sys", | |
| 2218 | + "glutin_glx_sys", | |
| 2219 | + "glutin_wgl_sys", | |
| 2220 | + "libloading", | |
| 2221 | + "objc2 0.6.4", | |
| 2222 | + "objc2-app-kit 0.3.2", | |
| 2223 | + "objc2-core-foundation", | |
| 2224 | + "objc2-foundation 0.3.2", | |
| 2225 | + "once_cell", | |
| 2226 | + "raw-window-handle", | |
| 2227 | + "wayland-sys", | |
| 2228 | + "windows-sys 0.52.0", | |
| 2229 | + "x11-dl", | |
| 2230 | +] | |
| 2231 | + | |
| 2232 | +[[package]] | |
| 2233 | +name = "glutin-winit" | |
| 2234 | +version = "0.5.0" | |
| 2235 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2236 | +checksum = "85edca7075f8fc728f28cb8fbb111a96c3b89e930574369e3e9c27eb75d3788f" | |
| 2237 | +dependencies = [ | |
| 2238 | + "cfg_aliases 0.2.2", | |
| 2239 | + "glutin", | |
| 2240 | + "raw-window-handle", | |
| 2241 | + "winit", | |
| 2242 | +] | |
| 2243 | + | |
| 2244 | +[[package]] | |
| 2245 | +name = "glutin_egl_sys" | |
| 2246 | +version = "0.7.1" | |
| 2247 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2248 | +checksum = "4c4680ba6195f424febdc3ba46e7a42a0e58743f2edb115297b86d7f8ecc02d2" | |
| 2249 | +dependencies = [ | |
| 2250 | + "gl_generator", | |
| 2251 | + "windows-sys 0.52.0", | |
| 2252 | +] | |
| 2253 | + | |
| 2254 | +[[package]] | |
| 2255 | +name = "glutin_glx_sys" | |
| 2256 | +version = "0.6.1" | |
| 2257 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2258 | +checksum = "8a7bb2938045a88b612499fbcba375a77198e01306f52272e692f8c1f3751185" | |
| 2259 | +dependencies = [ | |
| 2260 | + "gl_generator", | |
| 2261 | + "x11-dl", | |
| 2262 | +] | |
| 2263 | + | |
| 2264 | +[[package]] | |
| 2265 | +name = "glutin_wgl_sys" | |
| 2266 | +version = "0.6.1" | |
| 2267 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2268 | +checksum = "2c4ee00b289aba7a9e5306d57c2d05499b2e5dc427f84ac708bd2c090212cf3e" | |
| 2269 | +dependencies = [ | |
| 2270 | + "gl_generator", | |
| 2271 | +] | |
| 2272 | + | |
| 1758 | 2273 | [[package]] |
| 1759 | 2274 | name = "h2" |
| 1760 | 2275 | version = "0.4.15" |
| @@ -2224,6 +2739,8 @@ dependencies = [ | ||
| 2224 | 2739 | "byteorder-lite", |
| 2225 | 2740 | "moxcms", |
| 2226 | 2741 | "num-traits", |
| 2742 | + "png 0.18.1", | |
| 2743 | + "tiff", | |
| 2227 | 2744 | "zune-core", |
| 2228 | 2745 | "zune-jpeg", |
| 2229 | 2746 | ] |
| @@ -2750,6 +3267,12 @@ dependencies = [ | ||
| 2750 | 3267 | "wasm-bindgen", |
| 2751 | 3268 | ] |
| 2752 | 3269 | |
| 3270 | +[[package]] | |
| 3271 | +name = "khronos_api" | |
| 3272 | +version = "3.1.0" | |
| 3273 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3274 | +checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" | |
| 3275 | + | |
| 2753 | 3276 | [[package]] |
| 2754 | 3277 | name = "lazy_static" |
| 2755 | 3278 | version = "1.5.0" |
| @@ -2778,6 +3301,18 @@ version = "0.2.16" | ||
| 2778 | 3301 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 2779 | 3302 | checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" |
| 2780 | 3303 | |
| 3304 | +[[package]] | |
| 3305 | +name = "libredox" | |
| 3306 | +version = "0.1.21" | |
| 3307 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3308 | +checksum = "d7955dfc218a8afb29dfeffd540e3a6e96baeb94fe7138228dd7cc6937fbbf96" | |
| 3309 | +dependencies = [ | |
| 3310 | + "bitflags 2.13.1", | |
| 3311 | + "libc", | |
| 3312 | + "plain", | |
| 3313 | + "redox_syscall 0.9.3", | |
| 3314 | +] | |
| 3315 | + | |
| 2781 | 3316 | [[package]] |
| 2782 | 3317 | name = "libspa" |
| 2783 | 3318 | version = "0.9.2" |
| @@ -2799,12 +3334,24 @@ dependencies = [ | ||
| 2799 | 3334 | name = "libspa-sys" |
| 2800 | 3335 | version = "0.9.2" |
| 2801 | 3336 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 2802 | -checksum = "901049455d2eb6decf9058235d745237952f4804bc584c5fcb41412e6adcc6e0" | |
| 2803 | -dependencies = [ | |
| 2804 | - "bindgen 0.72.1", | |
| 2805 | - "cc", | |
| 2806 | - "system-deps", | |
| 2807 | -] | |
| 3337 | +checksum = "901049455d2eb6decf9058235d745237952f4804bc584c5fcb41412e6adcc6e0" | |
| 3338 | +dependencies = [ | |
| 3339 | + "bindgen 0.72.1", | |
| 3340 | + "cc", | |
| 3341 | + "system-deps", | |
| 3342 | +] | |
| 3343 | + | |
| 3344 | +[[package]] | |
| 3345 | +name = "linux-raw-sys" | |
| 3346 | +version = "0.4.15" | |
| 3347 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3348 | +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" | |
| 3349 | + | |
| 3350 | +[[package]] | |
| 3351 | +name = "linux-raw-sys" | |
| 3352 | +version = "0.12.1" | |
| 3353 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3354 | +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" | |
| 2808 | 3355 | |
| 2809 | 3356 | [[package]] |
| 2810 | 3357 | name = "litemap" |
| @@ -2891,6 +3438,24 @@ version = "2.8.3" | ||
| 2891 | 3438 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 2892 | 3439 | checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" |
| 2893 | 3440 | |
| 3441 | +[[package]] | |
| 3442 | +name = "memmap2" | |
| 3443 | +version = "0.9.11" | |
| 3444 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3445 | +checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0" | |
| 3446 | +dependencies = [ | |
| 3447 | + "libc", | |
| 3448 | +] | |
| 3449 | + | |
| 3450 | +[[package]] | |
| 3451 | +name = "memoffset" | |
| 3452 | +version = "0.9.1" | |
| 3453 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3454 | +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" | |
| 3455 | +dependencies = [ | |
| 3456 | + "autocfg", | |
| 3457 | +] | |
| 3458 | + | |
| 2894 | 3459 | [[package]] |
| 2895 | 3460 | name = "minimal-lexical" |
| 2896 | 3461 | version = "0.2.1" |
| @@ -2904,6 +3469,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2904 | 3469 | checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" |
| 2905 | 3470 | dependencies = [ |
| 2906 | 3471 | "adler2", |
| 3472 | + "simd-adler32", | |
| 3473 | +] | |
| 3474 | + | |
| 3475 | +[[package]] | |
| 3476 | +name = "miniz_oxide" | |
| 3477 | +version = "0.9.1" | |
| 3478 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3479 | +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" | |
| 3480 | +dependencies = [ | |
| 3481 | + "adler2", | |
| 3482 | + "simd-adler32", | |
| 2907 | 3483 | ] |
| 2908 | 3484 | |
| 2909 | 3485 | [[package]] |
| @@ -3024,6 +3600,20 @@ dependencies = [ | ||
| 3024 | 3600 | "pxfm", |
| 3025 | 3601 | ] |
| 3026 | 3602 | |
| 3603 | +[[package]] | |
| 3604 | +name = "mp4" | |
| 3605 | +version = "0.14.0" | |
| 3606 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3607 | +checksum = "c9ef834d5ed55e494a2ae350220314dc4aacd1c43a9498b00e320e0ea352a5c3" | |
| 3608 | +dependencies = [ | |
| 3609 | + "byteorder", | |
| 3610 | + "bytes", | |
| 3611 | + "num-rational", | |
| 3612 | + "serde", | |
| 3613 | + "serde_json", | |
| 3614 | + "thiserror 1.0.69", | |
| 3615 | +] | |
| 3616 | + | |
| 3027 | 3617 | [[package]] |
| 3028 | 3618 | name = "n0-error" |
| 3029 | 3619 | version = "0.1.3" |
| @@ -3098,6 +3688,7 @@ dependencies = [ | ||
| 3098 | 3688 | "log", |
| 3099 | 3689 | "ndk-sys", |
| 3100 | 3690 | "num_enum", |
| 3691 | + "raw-window-handle", | |
| 3101 | 3692 | "thiserror 1.0.69", |
| 3102 | 3693 | ] |
| 3103 | 3694 | |
| @@ -3122,7 +3713,7 @@ version = "0.40.1" | ||
| 3122 | 3713 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 3123 | 3714 | checksum = "1b0a0096d9613ee878dba89bbe595f079d373e3f1960d882e4f2f78ff9c30a0a" |
| 3124 | 3715 | dependencies = [ |
| 3125 | - "block2", | |
| 3716 | + "block2 0.6.2", | |
| 3126 | 3717 | "dispatch2", |
| 3127 | 3718 | "dlopen2", |
| 3128 | 3719 | "ipnet", |
| @@ -3247,6 +3838,12 @@ dependencies = [ | ||
| 3247 | 3838 | "libc", |
| 3248 | 3839 | ] |
| 3249 | 3840 | |
| 3841 | +[[package]] | |
| 3842 | +name = "nohash-hasher" | |
| 3843 | +version = "0.2.0" | |
| 3844 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3845 | +checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" | |
| 3846 | + | |
| 3250 | 3847 | [[package]] |
| 3251 | 3848 | name = "nom" |
| 3252 | 3849 | version = "7.1.3" |
| @@ -3404,6 +4001,18 @@ dependencies = [ | ||
| 3404 | 4001 | "num-traits", |
| 3405 | 4002 | ] |
| 3406 | 4003 | |
| 4004 | +[[package]] | |
| 4005 | +name = "num-rational" | |
| 4006 | +version = "0.4.2" | |
| 4007 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 4008 | +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" | |
| 4009 | +dependencies = [ | |
| 4010 | + "num-bigint", | |
| 4011 | + "num-integer", | |
| 4012 | + "num-traits", | |
| 4013 | + "serde", | |
| 4014 | +] | |
| 4015 | + | |
| 3407 | 4016 | [[package]] |
| 3408 | 4017 | name = "num-traits" |
| 3409 | 4018 | version = "0.2.19" |
| @@ -3454,6 +4063,22 @@ dependencies = [ | ||
| 3454 | 4063 | "libc", |
| 3455 | 4064 | ] |
| 3456 | 4065 | |
| 4066 | +[[package]] | |
| 4067 | +name = "objc-sys" | |
| 4068 | +version = "0.3.5" | |
| 4069 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 4070 | +checksum = "cdb91bdd390c7ce1a8607f35f3ca7151b65afc0ff5ff3b34fa350f7d7c7e4310" | |
| 4071 | + | |
| 4072 | +[[package]] | |
| 4073 | +name = "objc2" | |
| 4074 | +version = "0.5.2" | |
| 4075 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 4076 | +checksum = "46a785d4eeff09c14c487497c162e92766fbb3e4059a71840cecc03d9a50b804" | |
| 4077 | +dependencies = [ | |
| 4078 | + "objc-sys", | |
| 4079 | + "objc2-encode", | |
| 4080 | +] | |
| 4081 | + | |
| 3457 | 4082 | [[package]] |
| 3458 | 4083 | name = "objc2" |
| 3459 | 4084 | version = "0.6.4" |
| @@ -3463,6 +4088,35 @@ dependencies = [ | ||
| 3463 | 4088 | "objc2-encode", |
| 3464 | 4089 | ] |
| 3465 | 4090 | |
| 4091 | +[[package]] | |
| 4092 | +name = "objc2-app-kit" | |
| 4093 | +version = "0.2.2" | |
| 4094 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 4095 | +checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff" | |
| 4096 | +dependencies = [ | |
| 4097 | + "bitflags 2.13.1", | |
| 4098 | + "block2 0.5.1", | |
| 4099 | + "libc", | |
| 4100 | + "objc2 0.5.2", | |
| 4101 | + "objc2-core-data", | |
| 4102 | + "objc2-core-image 0.2.2", | |
| 4103 | + "objc2-foundation 0.2.2", | |
| 4104 | + "objc2-quartz-core 0.2.2", | |
| 4105 | +] | |
| 4106 | + | |
| 4107 | +[[package]] | |
| 4108 | +name = "objc2-app-kit" | |
| 4109 | +version = "0.3.2" | |
| 4110 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 4111 | +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" | |
| 4112 | +dependencies = [ | |
| 4113 | + "bitflags 2.13.1", | |
| 4114 | + "objc2 0.6.4", | |
| 4115 | + "objc2-core-foundation", | |
| 4116 | + "objc2-core-graphics", | |
| 4117 | + "objc2-foundation 0.3.2", | |
| 4118 | +] | |
| 4119 | + | |
| 3466 | 4120 | [[package]] |
| 3467 | 4121 | name = "objc2-audio-toolbox" |
| 3468 | 4122 | version = "0.3.2" |
| @@ -3471,11 +4125,11 @@ checksum = "6948501a91121d6399b79abaa33a8aa4ea7857fe019f341b8c23ad6e81b79b08" | ||
| 3471 | 4125 | dependencies = [ |
| 3472 | 4126 | "bitflags 2.13.1", |
| 3473 | 4127 | "libc", |
| 3474 | - "objc2", | |
| 4128 | + "objc2 0.6.4", | |
| 3475 | 4129 | "objc2-core-audio", |
| 3476 | 4130 | "objc2-core-audio-types", |
| 3477 | 4131 | "objc2-core-foundation", |
| 3478 | - "objc2-foundation", | |
| 4132 | + "objc2-foundation 0.3.2", | |
| 3479 | 4133 | ] |
| 3480 | 4134 | |
| 3481 | 4135 | [[package]] |
| @@ -3485,19 +4139,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 3485 | 4139 | checksum = "478ae33fcac9df0a18db8302387c666b8ef08a3e2d62b510ca4fc278a384b6c0" |
| 3486 | 4140 | dependencies = [ |
| 3487 | 4141 | "bitflags 2.13.1", |
| 3488 | - "block2", | |
| 4142 | + "block2 0.6.2", | |
| 3489 | 4143 | "dispatch2", |
| 3490 | - "objc2", | |
| 4144 | + "objc2 0.6.4", | |
| 3491 | 4145 | "objc2-avf-audio", |
| 3492 | 4146 | "objc2-core-audio-types", |
| 3493 | 4147 | "objc2-core-foundation", |
| 3494 | 4148 | "objc2-core-graphics", |
| 3495 | - "objc2-core-image", | |
| 4149 | + "objc2-core-image 0.3.2", | |
| 3496 | 4150 | "objc2-core-video", |
| 3497 | - "objc2-foundation", | |
| 4151 | + "objc2-foundation 0.3.2", | |
| 3498 | 4152 | "objc2-image-io", |
| 3499 | 4153 | "objc2-media-toolbox", |
| 3500 | - "objc2-quartz-core", | |
| 4154 | + "objc2-quartz-core 0.3.2", | |
| 3501 | 4155 | ] |
| 3502 | 4156 | |
| 3503 | 4157 | [[package]] |
| @@ -3506,8 +4160,32 @@ version = "0.3.2" | ||
| 3506 | 4160 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 3507 | 4161 | checksum = "13a380031deed8e99db00065c45937da434ca987c034e13b87e4441f9e4090be" |
| 3508 | 4162 | dependencies = [ |
| 3509 | - "objc2", | |
| 3510 | - "objc2-foundation", | |
| 4163 | + "objc2 0.6.4", | |
| 4164 | + "objc2-foundation 0.3.2", | |
| 4165 | +] | |
| 4166 | + | |
| 4167 | +[[package]] | |
| 4168 | +name = "objc2-cloud-kit" | |
| 4169 | +version = "0.2.2" | |
| 4170 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 4171 | +checksum = "74dd3b56391c7a0596a295029734d3c1c5e7e510a4cb30245f8221ccea96b009" | |
| 4172 | +dependencies = [ | |
| 4173 | + "bitflags 2.13.1", | |
| 4174 | + "block2 0.5.1", | |
| 4175 | + "objc2 0.5.2", | |
| 4176 | + "objc2-core-location", | |
| 4177 | + "objc2-foundation 0.2.2", | |
| 4178 | +] | |
| 4179 | + | |
| 4180 | +[[package]] | |
| 4181 | +name = "objc2-contacts" | |
| 4182 | +version = "0.2.2" | |
| 4183 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 4184 | +checksum = "a5ff520e9c33812fd374d8deecef01d4a840e7b41862d849513de77e44aa4889" | |
| 4185 | +dependencies = [ | |
| 4186 | + "block2 0.5.1", | |
| 4187 | + "objc2 0.5.2", | |
| 4188 | + "objc2-foundation 0.2.2", | |
| 3511 | 4189 | ] |
| 3512 | 4190 | |
| 3513 | 4191 | [[package]] |
| @@ -3517,10 +4195,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 3517 | 4195 | checksum = "e1eebcea8b0dbff5f7c8504f3107c68fc061a3eb44932051c8cf8a68d969c3b2" |
| 3518 | 4196 | dependencies = [ |
| 3519 | 4197 | "dispatch2", |
| 3520 | - "objc2", | |
| 4198 | + "objc2 0.6.4", | |
| 3521 | 4199 | "objc2-core-audio-types", |
| 3522 | 4200 | "objc2-core-foundation", |
| 3523 | - "objc2-foundation", | |
| 4201 | + "objc2-foundation 0.3.2", | |
| 3524 | 4202 | ] |
| 3525 | 4203 | |
| 3526 | 4204 | [[package]] |
| @@ -3530,7 +4208,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 3530 | 4208 | checksum = "5a89f2ec274a0cf4a32642b2991e8b351a404d290da87bb6a9a9d8632490bd1c" |
| 3531 | 4209 | dependencies = [ |
| 3532 | 4210 | "bitflags 2.13.1", |
| 3533 | - "objc2", | |
| 4211 | + "objc2 0.6.4", | |
| 4212 | +] | |
| 4213 | + | |
| 4214 | +[[package]] | |
| 4215 | +name = "objc2-core-data" | |
| 4216 | +version = "0.2.2" | |
| 4217 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 4218 | +checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef" | |
| 4219 | +dependencies = [ | |
| 4220 | + "bitflags 2.13.1", | |
| 4221 | + "block2 0.5.1", | |
| 4222 | + "objc2 0.5.2", | |
| 4223 | + "objc2-foundation 0.2.2", | |
| 3534 | 4224 | ] |
| 3535 | 4225 | |
| 3536 | 4226 | [[package]] |
| @@ -3540,10 +4230,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 3540 | 4230 | checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" |
| 3541 | 4231 | dependencies = [ |
| 3542 | 4232 | "bitflags 2.13.1", |
| 3543 | - "block2", | |
| 4233 | + "block2 0.6.2", | |
| 3544 | 4234 | "dispatch2", |
| 3545 | 4235 | "libc", |
| 3546 | - "objc2", | |
| 4236 | + "objc2 0.6.4", | |
| 3547 | 4237 | ] |
| 3548 | 4238 | |
| 3549 | 4239 | [[package]] |
| @@ -3554,19 +4244,43 @@ checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" | ||
| 3554 | 4244 | dependencies = [ |
| 3555 | 4245 | "bitflags 2.13.1", |
| 3556 | 4246 | "dispatch2", |
| 3557 | - "objc2", | |
| 4247 | + "objc2 0.6.4", | |
| 3558 | 4248 | "objc2-core-foundation", |
| 3559 | 4249 | "objc2-io-surface", |
| 3560 | 4250 | ] |
| 3561 | 4251 | |
| 4252 | +[[package]] | |
| 4253 | +name = "objc2-core-image" | |
| 4254 | +version = "0.2.2" | |
| 4255 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 4256 | +checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80" | |
| 4257 | +dependencies = [ | |
| 4258 | + "block2 0.5.1", | |
| 4259 | + "objc2 0.5.2", | |
| 4260 | + "objc2-foundation 0.2.2", | |
| 4261 | + "objc2-metal 0.2.2", | |
| 4262 | +] | |
| 4263 | + | |
| 3562 | 4264 | [[package]] |
| 3563 | 4265 | name = "objc2-core-image" |
| 3564 | 4266 | version = "0.3.2" |
| 3565 | 4267 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 3566 | 4268 | checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006" |
| 3567 | 4269 | dependencies = [ |
| 3568 | - "objc2", | |
| 3569 | - "objc2-foundation", | |
| 4270 | + "objc2 0.6.4", | |
| 4271 | + "objc2-foundation 0.3.2", | |
| 4272 | +] | |
| 4273 | + | |
| 4274 | +[[package]] | |
| 4275 | +name = "objc2-core-location" | |
| 4276 | +version = "0.2.2" | |
| 4277 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 4278 | +checksum = "000cfee34e683244f284252ee206a27953279d370e309649dc3ee317b37e5781" | |
| 4279 | +dependencies = [ | |
| 4280 | + "block2 0.5.1", | |
| 4281 | + "objc2 0.5.2", | |
| 4282 | + "objc2-contacts", | |
| 4283 | + "objc2-foundation 0.2.2", | |
| 3570 | 4284 | ] |
| 3571 | 4285 | |
| 3572 | 4286 | [[package]] |
| @@ -3576,9 +4290,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 3576 | 4290 | checksum = "05ec576860167a15dd9fce7fbee7512beb4e31f532159d3482d1f9c6caedf31d" |
| 3577 | 4291 | dependencies = [ |
| 3578 | 4292 | "bitflags 2.13.1", |
| 3579 | - "block2", | |
| 4293 | + "block2 0.6.2", | |
| 3580 | 4294 | "dispatch2", |
| 3581 | - "objc2", | |
| 4295 | + "objc2 0.6.4", | |
| 3582 | 4296 | "objc2-core-audio", |
| 3583 | 4297 | "objc2-core-audio-types", |
| 3584 | 4298 | "objc2-core-foundation", |
| @@ -3592,12 +4306,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 3592 | 4306 | checksum = "d425caf1df73233f29fd8a5c3e5edbc30d2d4307870f802d18f00d83dc5141a6" |
| 3593 | 4307 | dependencies = [ |
| 3594 | 4308 | "bitflags 2.13.1", |
| 3595 | - "block2", | |
| 3596 | - "objc2", | |
| 4309 | + "block2 0.6.2", | |
| 4310 | + "objc2 0.6.4", | |
| 3597 | 4311 | "objc2-core-foundation", |
| 3598 | 4312 | "objc2-core-graphics", |
| 3599 | 4313 | "objc2-io-surface", |
| 3600 | - "objc2-metal", | |
| 4314 | + "objc2-metal 0.3.2", | |
| 3601 | 4315 | ] |
| 3602 | 4316 | |
| 3603 | 4317 | [[package]] |
| @@ -3606,6 +4320,19 @@ version = "4.1.0" | ||
| 3606 | 4320 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 3607 | 4321 | checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" |
| 3608 | 4322 | |
| 4323 | +[[package]] | |
| 4324 | +name = "objc2-foundation" | |
| 4325 | +version = "0.2.2" | |
| 4326 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 4327 | +checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" | |
| 4328 | +dependencies = [ | |
| 4329 | + "bitflags 2.13.1", | |
| 4330 | + "block2 0.5.1", | |
| 4331 | + "dispatch", | |
| 4332 | + "libc", | |
| 4333 | + "objc2 0.5.2", | |
| 4334 | +] | |
| 4335 | + | |
| 3609 | 4336 | [[package]] |
| 3610 | 4337 | name = "objc2-foundation" |
| 3611 | 4338 | version = "0.3.2" |
| @@ -3613,9 +4340,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 3613 | 4340 | checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" |
| 3614 | 4341 | dependencies = [ |
| 3615 | 4342 | "bitflags 2.13.1", |
| 3616 | - "block2", | |
| 4343 | + "block2 0.6.2", | |
| 3617 | 4344 | "libc", |
| 3618 | - "objc2", | |
| 4345 | + "objc2 0.6.4", | |
| 3619 | 4346 | "objc2-core-foundation", |
| 3620 | 4347 | ] |
| 3621 | 4348 | |
| @@ -3625,7 +4352,7 @@ version = "0.3.2" | ||
| 3625 | 4352 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 3626 | 4353 | checksum = "32b0446e98cf4a784cc7a0177715ff317eeaa8463841c616cfc78aa4f953c4ea" |
| 3627 | 4354 | dependencies = [ |
| 3628 | - "objc2", | |
| 4355 | + "objc2 0.6.4", | |
| 3629 | 4356 | "objc2-core-foundation", |
| 3630 | 4357 | "objc2-core-graphics", |
| 3631 | 4358 | ] |
| @@ -3637,22 +4364,46 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 3637 | 4364 | checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" |
| 3638 | 4365 | dependencies = [ |
| 3639 | 4366 | "bitflags 2.13.1", |
| 3640 | - "objc2", | |
| 4367 | + "objc2 0.6.4", | |
| 3641 | 4368 | "objc2-core-foundation", |
| 3642 | 4369 | ] |
| 3643 | 4370 | |
| 4371 | +[[package]] | |
| 4372 | +name = "objc2-link-presentation" | |
| 4373 | +version = "0.2.2" | |
| 4374 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 4375 | +checksum = "a1a1ae721c5e35be65f01a03b6d2ac13a54cb4fa70d8a5da293d7b0020261398" | |
| 4376 | +dependencies = [ | |
| 4377 | + "block2 0.5.1", | |
| 4378 | + "objc2 0.5.2", | |
| 4379 | + "objc2-app-kit 0.2.2", | |
| 4380 | + "objc2-foundation 0.2.2", | |
| 4381 | +] | |
| 4382 | + | |
| 3644 | 4383 | [[package]] |
| 3645 | 4384 | name = "objc2-media-toolbox" |
| 3646 | 4385 | version = "0.3.2" |
| 3647 | 4386 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 3648 | 4387 | checksum = "edd9fdde720df3da7046bb9097811000c1e7ab5cd579fa89d96b27d56781fb30" |
| 3649 | 4388 | dependencies = [ |
| 3650 | - "objc2", | |
| 4389 | + "objc2 0.6.4", | |
| 3651 | 4390 | "objc2-core-audio-types", |
| 3652 | 4391 | "objc2-core-foundation", |
| 3653 | 4392 | "objc2-core-media", |
| 3654 | 4393 | ] |
| 3655 | 4394 | |
| 4395 | +[[package]] | |
| 4396 | +name = "objc2-metal" | |
| 4397 | +version = "0.2.2" | |
| 4398 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 4399 | +checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" | |
| 4400 | +dependencies = [ | |
| 4401 | + "bitflags 2.13.1", | |
| 4402 | + "block2 0.5.1", | |
| 4403 | + "objc2 0.5.2", | |
| 4404 | + "objc2-foundation 0.2.2", | |
| 4405 | +] | |
| 4406 | + | |
| 3656 | 4407 | [[package]] |
| 3657 | 4408 | name = "objc2-metal" |
| 3658 | 4409 | version = "0.3.2" |
| @@ -3660,8 +4411,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 3660 | 4411 | checksum = "a0125f776a10d00af4152d74616409f0d4a2053a6f57fa5b7d6aa2854ac04794" |
| 3661 | 4412 | dependencies = [ |
| 3662 | 4413 | "bitflags 2.13.1", |
| 3663 | - "objc2", | |
| 3664 | - "objc2-foundation", | |
| 4414 | + "objc2 0.6.4", | |
| 4415 | + "objc2-foundation 0.3.2", | |
| 4416 | +] | |
| 4417 | + | |
| 4418 | +[[package]] | |
| 4419 | +name = "objc2-quartz-core" | |
| 4420 | +version = "0.2.2" | |
| 4421 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 4422 | +checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" | |
| 4423 | +dependencies = [ | |
| 4424 | + "bitflags 2.13.1", | |
| 4425 | + "block2 0.5.1", | |
| 4426 | + "objc2 0.5.2", | |
| 4427 | + "objc2-foundation 0.2.2", | |
| 4428 | + "objc2-metal 0.2.2", | |
| 3665 | 4429 | ] |
| 3666 | 4430 | |
| 3667 | 4431 | [[package]] |
| @@ -3671,8 +4435,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 3671 | 4435 | checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" |
| 3672 | 4436 | dependencies = [ |
| 3673 | 4437 | "bitflags 2.13.1", |
| 3674 | - "objc2", | |
| 3675 | - "objc2-foundation", | |
| 4438 | + "objc2 0.6.4", | |
| 4439 | + "objc2-foundation 0.3.2", | |
| 3676 | 4440 | ] |
| 3677 | 4441 | |
| 3678 | 4442 | [[package]] |
| @@ -3682,10 +4446,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 3682 | 4446 | checksum = "709fe137109bd1e8b5a99390f77a7d8b2961dafc1a1c5db8f2e60329ad6d895a" |
| 3683 | 4447 | dependencies = [ |
| 3684 | 4448 | "bitflags 2.13.1", |
| 3685 | - "objc2", | |
| 4449 | + "objc2 0.6.4", | |
| 3686 | 4450 | "objc2-core-foundation", |
| 3687 | 4451 | ] |
| 3688 | 4452 | |
| 4453 | +[[package]] | |
| 4454 | +name = "objc2-symbols" | |
| 4455 | +version = "0.2.2" | |
| 4456 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 4457 | +checksum = "0a684efe3dec1b305badae1a28f6555f6ddd3bb2c2267896782858d5a78404dc" | |
| 4458 | +dependencies = [ | |
| 4459 | + "objc2 0.5.2", | |
| 4460 | + "objc2-foundation 0.2.2", | |
| 4461 | +] | |
| 4462 | + | |
| 3689 | 4463 | [[package]] |
| 3690 | 4464 | name = "objc2-system-configuration" |
| 3691 | 4465 | version = "0.3.2" |
| @@ -3695,11 +4469,56 @@ dependencies = [ | ||
| 3695 | 4469 | "bitflags 2.13.1", |
| 3696 | 4470 | "dispatch2", |
| 3697 | 4471 | "libc", |
| 3698 | - "objc2", | |
| 4472 | + "objc2 0.6.4", | |
| 3699 | 4473 | "objc2-core-foundation", |
| 3700 | 4474 | "objc2-security", |
| 3701 | 4475 | ] |
| 3702 | 4476 | |
| 4477 | +[[package]] | |
| 4478 | +name = "objc2-ui-kit" | |
| 4479 | +version = "0.2.2" | |
| 4480 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 4481 | +checksum = "b8bb46798b20cd6b91cbd113524c490f1686f4c4e8f49502431415f3512e2b6f" | |
| 4482 | +dependencies = [ | |
| 4483 | + "bitflags 2.13.1", | |
| 4484 | + "block2 0.5.1", | |
| 4485 | + "objc2 0.5.2", | |
| 4486 | + "objc2-cloud-kit", | |
| 4487 | + "objc2-core-data", | |
| 4488 | + "objc2-core-image 0.2.2", | |
| 4489 | + "objc2-core-location", | |
| 4490 | + "objc2-foundation 0.2.2", | |
| 4491 | + "objc2-link-presentation", | |
| 4492 | + "objc2-quartz-core 0.2.2", | |
| 4493 | + "objc2-symbols", | |
| 4494 | + "objc2-uniform-type-identifiers", | |
| 4495 | + "objc2-user-notifications", | |
| 4496 | +] | |
| 4497 | + | |
| 4498 | +[[package]] | |
| 4499 | +name = "objc2-uniform-type-identifiers" | |
| 4500 | +version = "0.2.2" | |
| 4501 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 4502 | +checksum = "44fa5f9748dbfe1ca6c0b79ad20725a11eca7c2218bceb4b005cb1be26273bfe" | |
| 4503 | +dependencies = [ | |
| 4504 | + "block2 0.5.1", | |
| 4505 | + "objc2 0.5.2", | |
| 4506 | + "objc2-foundation 0.2.2", | |
| 4507 | +] | |
| 4508 | + | |
| 4509 | +[[package]] | |
| 4510 | +name = "objc2-user-notifications" | |
| 4511 | +version = "0.2.2" | |
| 4512 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 4513 | +checksum = "76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3" | |
| 4514 | +dependencies = [ | |
| 4515 | + "bitflags 2.13.1", | |
| 4516 | + "block2 0.5.1", | |
| 4517 | + "objc2 0.5.2", | |
| 4518 | + "objc2-core-location", | |
| 4519 | + "objc2-foundation 0.2.2", | |
| 4520 | +] | |
| 4521 | + | |
| 3703 | 4522 | [[package]] |
| 3704 | 4523 | name = "object" |
| 3705 | 4524 | version = "0.37.3" |
| @@ -3767,6 +4586,25 @@ version = "0.2.1" | ||
| 3767 | 4586 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 3768 | 4587 | checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" |
| 3769 | 4588 | |
| 4589 | +[[package]] | |
| 4590 | +name = "orbclient" | |
| 4591 | +version = "0.3.55" | |
| 4592 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 4593 | +checksum = "5df339f526ea9a60e371768d50efc2f2508c7203290731565d1f7a6f71d21747" | |
| 4594 | +dependencies = [ | |
| 4595 | + "libc", | |
| 4596 | + "libredox", | |
| 4597 | +] | |
| 4598 | + | |
| 4599 | +[[package]] | |
| 4600 | +name = "owned_ttf_parser" | |
| 4601 | +version = "0.25.1" | |
| 4602 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 4603 | +checksum = "36820e9051aca1014ddc75770aab4d68bc1e9e632f0f5627c4086bc216fb583b" | |
| 4604 | +dependencies = [ | |
| 4605 | + "ttf-parser", | |
| 4606 | +] | |
| 4607 | + | |
| 3770 | 4608 | [[package]] |
| 3771 | 4609 | name = "papaya" |
| 3772 | 4610 | version = "0.2.4" |
| @@ -3803,7 +4641,7 @@ dependencies = [ | ||
| 3803 | 4641 | "cfg-if", |
| 3804 | 4642 | "libc", |
| 3805 | 4643 | "petgraph", |
| 3806 | - "redox_syscall", | |
| 4644 | + "redox_syscall 0.5.18", | |
| 3807 | 4645 | "smallvec", |
| 3808 | 4646 | "windows-link", |
| 3809 | 4647 | ] |
| @@ -3957,6 +4795,12 @@ version = "0.3.33" | ||
| 3957 | 4795 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 3958 | 4796 | checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" |
| 3959 | 4797 | |
| 4798 | +[[package]] | |
| 4799 | +name = "plain" | |
| 4800 | +version = "0.2.3" | |
| 4801 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 4802 | +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" | |
| 4803 | + | |
| 3960 | 4804 | [[package]] |
| 3961 | 4805 | name = "plist" |
| 3962 | 4806 | version = "1.10.0" |
| @@ -3970,6 +4814,46 @@ dependencies = [ | ||
| 3970 | 4814 | "time", |
| 3971 | 4815 | ] |
| 3972 | 4816 | |
| 4817 | +[[package]] | |
| 4818 | +name = "png" | |
| 4819 | +version = "0.17.16" | |
| 4820 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 4821 | +checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" | |
| 4822 | +dependencies = [ | |
| 4823 | + "bitflags 1.3.2", | |
| 4824 | + "crc32fast", | |
| 4825 | + "fdeflate", | |
| 4826 | + "flate2", | |
| 4827 | + "miniz_oxide 0.8.9", | |
| 4828 | +] | |
| 4829 | + | |
| 4830 | +[[package]] | |
| 4831 | +name = "png" | |
| 4832 | +version = "0.18.1" | |
| 4833 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 4834 | +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" | |
| 4835 | +dependencies = [ | |
| 4836 | + "bitflags 2.13.1", | |
| 4837 | + "crc32fast", | |
| 4838 | + "fdeflate", | |
| 4839 | + "flate2", | |
| 4840 | + "miniz_oxide 0.8.9", | |
| 4841 | +] | |
| 4842 | + | |
| 4843 | +[[package]] | |
| 4844 | +name = "polling" | |
| 4845 | +version = "3.11.0" | |
| 4846 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 4847 | +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" | |
| 4848 | +dependencies = [ | |
| 4849 | + "cfg-if", | |
| 4850 | + "concurrent-queue", | |
| 4851 | + "hermit-abi", | |
| 4852 | + "pin-project-lite", | |
| 4853 | + "rustix 1.1.4", | |
| 4854 | + "windows-sys 0.61.2", | |
| 4855 | +] | |
| 4856 | + | |
| 3973 | 4857 | [[package]] |
| 3974 | 4858 | name = "polyval" |
| 3975 | 4859 | version = "0.6.2" |
| @@ -4116,6 +5000,12 @@ dependencies = [ | ||
| 4116 | 5000 | "unicode-ident", |
| 4117 | 5001 | ] |
| 4118 | 5002 | |
| 5003 | +[[package]] | |
| 5004 | +name = "profiling" | |
| 5005 | +version = "1.0.18" | |
| 5006 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 5007 | +checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" | |
| 5008 | + | |
| 4119 | 5009 | [[package]] |
| 4120 | 5010 | name = "pxfm" |
| 4121 | 5011 | version = "0.1.30" |
| @@ -4140,6 +5030,12 @@ dependencies = [ | ||
| 4140 | 5030 | "web-transport-trait", |
| 4141 | 5031 | ] |
| 4142 | 5032 | |
| 5033 | +[[package]] | |
| 5034 | +name = "quick-error" | |
| 5035 | +version = "2.0.1" | |
| 5036 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 5037 | +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" | |
| 5038 | + | |
| 4143 | 5039 | [[package]] |
| 4144 | 5040 | name = "quick-xml" |
| 4145 | 5041 | version = "0.41.0" |
| @@ -4314,6 +5210,12 @@ dependencies = [ | ||
| 4314 | 5210 | "rand_core 0.10.1", |
| 4315 | 5211 | ] |
| 4316 | 5212 | |
| 5213 | +[[package]] | |
| 5214 | +name = "raw-window-handle" | |
| 5215 | +version = "0.6.2" | |
| 5216 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 5217 | +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" | |
| 5218 | + | |
| 4317 | 5219 | [[package]] |
| 4318 | 5220 | name = "rayon" |
| 4319 | 5221 | version = "1.12.0" |
| @@ -4356,6 +5258,15 @@ dependencies = [ | ||
| 4356 | 5258 | "rustfft", |
| 4357 | 5259 | ] |
| 4358 | 5260 | |
| 5261 | +[[package]] | |
| 5262 | +name = "redox_syscall" | |
| 5263 | +version = "0.4.1" | |
| 5264 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 5265 | +checksum = "4722d768eff46b75989dd134e5c353f0d6296e5aaa3132e776cbdb56be7731aa" | |
| 5266 | +dependencies = [ | |
| 5267 | + "bitflags 1.3.2", | |
| 5268 | +] | |
| 5269 | + | |
| 4359 | 5270 | [[package]] |
| 4360 | 5271 | name = "redox_syscall" |
| 4361 | 5272 | version = "0.5.18" |
| @@ -4365,6 +5276,15 @@ dependencies = [ | ||
| 4365 | 5276 | "bitflags 2.13.1", |
| 4366 | 5277 | ] |
| 4367 | 5278 | |
| 5279 | +[[package]] | |
| 5280 | +name = "redox_syscall" | |
| 5281 | +version = "0.9.3" | |
| 5282 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 5283 | +checksum = "d678d17679829e73d371e96880897e98fee2ded7acc0a50bdf8af2affa4b2fe5" | |
| 5284 | +dependencies = [ | |
| 5285 | + "bitflags 2.13.1", | |
| 5286 | +] | |
| 5287 | + | |
| 4368 | 5288 | [[package]] |
| 4369 | 5289 | name = "ref-cast" |
| 4370 | 5290 | version = "1.0.26" |
| @@ -4603,6 +5523,32 @@ dependencies = [ | ||
| 4603 | 5523 | "nom 7.1.3", |
| 4604 | 5524 | ] |
| 4605 | 5525 | |
| 5526 | +[[package]] | |
| 5527 | +name = "rustix" | |
| 5528 | +version = "0.38.44" | |
| 5529 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 5530 | +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" | |
| 5531 | +dependencies = [ | |
| 5532 | + "bitflags 2.13.1", | |
| 5533 | + "errno", | |
| 5534 | + "libc", | |
| 5535 | + "linux-raw-sys 0.4.15", | |
| 5536 | + "windows-sys 0.59.0", | |
| 5537 | +] | |
| 5538 | + | |
| 5539 | +[[package]] | |
| 5540 | +name = "rustix" | |
| 5541 | +version = "1.1.4" | |
| 5542 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 5543 | +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" | |
| 5544 | +dependencies = [ | |
| 5545 | + "bitflags 2.13.1", | |
| 5546 | + "errno", | |
| 5547 | + "libc", | |
| 5548 | + "linux-raw-sys 0.12.1", | |
| 5549 | + "windows-sys 0.61.2", | |
| 5550 | +] | |
| 5551 | + | |
| 4606 | 5552 | [[package]] |
| 4607 | 5553 | name = "rustls" |
| 4608 | 5554 | version = "0.23.43" |
| @@ -4655,7 +5601,7 @@ version = "0.6.2" | ||
| 4655 | 5601 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 4656 | 5602 | checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784" |
| 4657 | 5603 | dependencies = [ |
| 4658 | - "core-foundation", | |
| 5604 | + "core-foundation 0.10.1", | |
| 4659 | 5605 | "core-foundation-sys", |
| 4660 | 5606 | "jni 0.21.1", |
| 4661 | 5607 | "log", |
| @@ -4676,7 +5622,7 @@ version = "0.7.0" | ||
| 4676 | 5622 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 4677 | 5623 | checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" |
| 4678 | 5624 | dependencies = [ |
| 4679 | - "core-foundation", | |
| 5625 | + "core-foundation 0.10.1", | |
| 4680 | 5626 | "core-foundation-sys", |
| 4681 | 5627 | "jni 0.22.4", |
| 4682 | 5628 | "log", |
| @@ -4721,18 +5667,18 @@ version = "0.1.0" | ||
| 4721 | 5667 | source = "git+https://github.com/n0-computer/iroh-live?rev=edd9bcc530a615f3ab3a6919e96790a077905376#edd9bcc530a615f3ab3a6919e96790a077905376" |
| 4722 | 5668 | dependencies = [ |
| 4723 | 5669 | "anyhow", |
| 4724 | - "block2", | |
| 5670 | + "block2 0.6.2", | |
| 4725 | 5671 | "bytes", |
| 4726 | 5672 | "derive_more", |
| 4727 | 5673 | "dispatch2", |
| 4728 | 5674 | "image", |
| 4729 | 5675 | "libc", |
| 4730 | 5676 | "nix 0.30.1", |
| 4731 | - "objc2", | |
| 5677 | + "objc2 0.6.4", | |
| 4732 | 5678 | "objc2-av-foundation", |
| 4733 | 5679 | "objc2-core-media", |
| 4734 | 5680 | "objc2-core-video", |
| 4735 | - "objc2-foundation", | |
| 5681 | + "objc2-foundation 0.3.2", | |
| 4736 | 5682 | "rusty-codecs", |
| 4737 | 5683 | "tracing", |
| 4738 | 5684 | "v4l2r", |
| @@ -4829,7 +5775,20 @@ checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" | ||
| 4829 | 5775 | name = "scopeguard" |
| 4830 | 5776 | version = "1.2.0" |
| 4831 | 5777 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 4832 | -checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" | |
| 5778 | +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" | |
| 5779 | + | |
| 5780 | +[[package]] | |
| 5781 | +name = "sctk-adwaita" | |
| 5782 | +version = "0.10.1" | |
| 5783 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 5784 | +checksum = "b6277f0217056f77f1d8f49f2950ac6c278c0d607c45f5ee99328d792ede24ec" | |
| 5785 | +dependencies = [ | |
| 5786 | + "ab_glyph", | |
| 5787 | + "log", | |
| 5788 | + "memmap2", | |
| 5789 | + "smithay-client-toolkit 0.19.2", | |
| 5790 | + "tiny-skia", | |
| 5791 | +] | |
| 4833 | 5792 | |
| 4834 | 5793 | [[package]] |
| 4835 | 5794 | name = "security-framework" |
| @@ -4838,7 +5797,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 4838 | 5797 | checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" |
| 4839 | 5798 | dependencies = [ |
| 4840 | 5799 | "bitflags 2.13.1", |
| 4841 | - "core-foundation", | |
| 5800 | + "core-foundation 0.10.1", | |
| 4842 | 5801 | "core-foundation-sys", |
| 4843 | 5802 | "libc", |
| 4844 | 5803 | "security-framework-sys", |
| @@ -5073,6 +6032,12 @@ version = "3.0.0-rc.10" | ||
| 5073 | 6032 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 5074 | 6033 | checksum = "7f1880df446116126965eeec169136b2e0251dba37c6223bcc819569550edea3" |
| 5075 | 6034 | |
| 6035 | +[[package]] | |
| 6036 | +name = "simd-adler32" | |
| 6037 | +version = "0.3.10" | |
| 6038 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 6039 | +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" | |
| 6040 | + | |
| 5076 | 6041 | [[package]] |
| 5077 | 6042 | name = "simd_cesu8" |
| 5078 | 6043 | version = "1.2.0" |
| @@ -5110,12 +6075,93 @@ version = "0.4.12" | ||
| 5110 | 6075 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 5111 | 6076 | checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" |
| 5112 | 6077 | |
| 6078 | +[[package]] | |
| 6079 | +name = "slotmap" | |
| 6080 | +version = "1.1.1" | |
| 6081 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 6082 | +checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" | |
| 6083 | +dependencies = [ | |
| 6084 | + "version_check", | |
| 6085 | +] | |
| 6086 | + | |
| 5113 | 6087 | [[package]] |
| 5114 | 6088 | name = "smallvec" |
| 5115 | 6089 | version = "1.15.2" |
| 5116 | 6090 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 5117 | 6091 | checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" |
| 5118 | 6092 | |
| 6093 | +[[package]] | |
| 6094 | +name = "smithay-client-toolkit" | |
| 6095 | +version = "0.19.2" | |
| 6096 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 6097 | +checksum = "3457dea1f0eb631b4034d61d4d8c32074caa6cd1ab2d59f2327bd8461e2c0016" | |
| 6098 | +dependencies = [ | |
| 6099 | + "bitflags 2.13.1", | |
| 6100 | + "calloop 0.13.0", | |
| 6101 | + "calloop-wayland-source 0.3.0", | |
| 6102 | + "cursor-icon", | |
| 6103 | + "libc", | |
| 6104 | + "log", | |
| 6105 | + "memmap2", | |
| 6106 | + "rustix 0.38.44", | |
| 6107 | + "thiserror 1.0.69", | |
| 6108 | + "wayland-backend", | |
| 6109 | + "wayland-client", | |
| 6110 | + "wayland-csd-frame", | |
| 6111 | + "wayland-cursor", | |
| 6112 | + "wayland-protocols", | |
| 6113 | + "wayland-protocols-wlr", | |
| 6114 | + "wayland-scanner", | |
| 6115 | + "xkeysym", | |
| 6116 | +] | |
| 6117 | + | |
| 6118 | +[[package]] | |
| 6119 | +name = "smithay-client-toolkit" | |
| 6120 | +version = "0.20.0" | |
| 6121 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 6122 | +checksum = "0512da38f5e2b31201a93524adb8d3136276fa4fe4aafab4e1f727a82b534cc0" | |
| 6123 | +dependencies = [ | |
| 6124 | + "bitflags 2.13.1", | |
| 6125 | + "calloop 0.14.4", | |
| 6126 | + "calloop-wayland-source 0.4.1", | |
| 6127 | + "cursor-icon", | |
| 6128 | + "libc", | |
| 6129 | + "log", | |
| 6130 | + "memmap2", | |
| 6131 | + "rustix 1.1.4", | |
| 6132 | + "thiserror 2.0.19", | |
| 6133 | + "wayland-backend", | |
| 6134 | + "wayland-client", | |
| 6135 | + "wayland-csd-frame", | |
| 6136 | + "wayland-cursor", | |
| 6137 | + "wayland-protocols", | |
| 6138 | + "wayland-protocols-experimental", | |
| 6139 | + "wayland-protocols-misc", | |
| 6140 | + "wayland-protocols-wlr", | |
| 6141 | + "wayland-scanner", | |
| 6142 | + "xkeysym", | |
| 6143 | +] | |
| 6144 | + | |
| 6145 | +[[package]] | |
| 6146 | +name = "smithay-clipboard" | |
| 6147 | +version = "0.7.3" | |
| 6148 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 6149 | +checksum = "71704c03f739f7745053bde45fa203a46c58d25bc5c4efba1d9a60e9dba81226" | |
| 6150 | +dependencies = [ | |
| 6151 | + "libc", | |
| 6152 | + "smithay-client-toolkit 0.20.0", | |
| 6153 | + "wayland-backend", | |
| 6154 | +] | |
| 6155 | + | |
| 6156 | +[[package]] | |
| 6157 | +name = "smol_str" | |
| 6158 | +version = "0.2.2" | |
| 6159 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 6160 | +checksum = "dd538fb6910ac1099850255cf94a94df6551fbdd602454387d0adb2d1ca6dead" | |
| 6161 | +dependencies = [ | |
| 6162 | + "serde", | |
| 6163 | +] | |
| 6164 | + | |
| 5119 | 6165 | [[package]] |
| 5120 | 6166 | name = "socket2" |
| 5121 | 6167 | version = "0.6.5" |
| @@ -5253,6 +6299,12 @@ version = "0.2.4" | ||
| 5253 | 6299 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 5254 | 6300 | checksum = "fe895eb47f22e2ddd4dabc02bce419d2e643c8e3b585c78158b349195bc24d82" |
| 5255 | 6301 | |
| 6302 | +[[package]] | |
| 6303 | +name = "strict-num" | |
| 6304 | +version = "0.1.1" | |
| 6305 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 6306 | +checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" | |
| 6307 | + | |
| 5256 | 6308 | [[package]] |
| 5257 | 6309 | name = "strsim" |
| 5258 | 6310 | version = "0.11.1" |
| @@ -5486,6 +6538,20 @@ dependencies = [ | ||
| 5486 | 6538 | "tracing", |
| 5487 | 6539 | ] |
| 5488 | 6540 | |
| 6541 | +[[package]] | |
| 6542 | +name = "tiff" | |
| 6543 | +version = "0.11.3" | |
| 6544 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 6545 | +checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52" | |
| 6546 | +dependencies = [ | |
| 6547 | + "fax", | |
| 6548 | + "flate2", | |
| 6549 | + "half", | |
| 6550 | + "quick-error", | |
| 6551 | + "weezl", | |
| 6552 | + "zune-jpeg", | |
| 6553 | +] | |
| 6554 | + | |
| 5489 | 6555 | [[package]] |
| 5490 | 6556 | name = "time" |
| 5491 | 6557 | version = "0.3.54" |
| @@ -5519,6 +6585,31 @@ dependencies = [ | ||
| 5519 | 6585 | "time-core", |
| 5520 | 6586 | ] |
| 5521 | 6587 | |
| 6588 | +[[package]] | |
| 6589 | +name = "tiny-skia" | |
| 6590 | +version = "0.11.4" | |
| 6591 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 6592 | +checksum = "83d13394d44dae3207b52a326c0c85a8bf87f1541f23b0d143811088497b09ab" | |
| 6593 | +dependencies = [ | |
| 6594 | + "arrayref", | |
| 6595 | + "arrayvec", | |
| 6596 | + "bytemuck", | |
| 6597 | + "cfg-if", | |
| 6598 | + "log", | |
| 6599 | + "tiny-skia-path", | |
| 6600 | +] | |
| 6601 | + | |
| 6602 | +[[package]] | |
| 6603 | +name = "tiny-skia-path" | |
| 6604 | +version = "0.11.4" | |
| 6605 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 6606 | +checksum = "9c9e7fc0c2e86a30b117d0462aa261b72b7a99b7ebd7deb3a14ceda95c5bdc93" | |
| 6607 | +dependencies = [ | |
| 6608 | + "arrayref", | |
| 6609 | + "bytemuck", | |
| 6610 | + "strict-num", | |
| 6611 | +] | |
| 6612 | + | |
| 5522 | 6613 | [[package]] |
| 5523 | 6614 | name = "tinystr" |
| 5524 | 6615 | version = "0.8.3" |
| @@ -5828,6 +6919,12 @@ version = "0.2.5" | ||
| 5828 | 6919 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 5829 | 6920 | checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" |
| 5830 | 6921 | |
| 6922 | +[[package]] | |
| 6923 | +name = "ttf-parser" | |
| 6924 | +version = "0.25.1" | |
| 6925 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 6926 | +checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" | |
| 6927 | + | |
| 5831 | 6928 | [[package]] |
| 5832 | 6929 | name = "tungstenite" |
| 5833 | 6930 | version = "0.28.0" |
| @@ -6040,6 +7137,35 @@ version = "0.9.5" | ||
| 6040 | 7137 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 6041 | 7138 | checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" |
| 6042 | 7139 | |
| 7140 | +[[package]] | |
| 7141 | +name = "vidya" | |
| 7142 | +version = "0.1.0" | |
| 7143 | +dependencies = [ | |
| 7144 | + "egui", | |
| 7145 | + "mp4", | |
| 7146 | + "openh264", | |
| 7147 | + "png 0.17.16", | |
| 7148 | + "winit", | |
| 7149 | + "zip", | |
| 7150 | +] | |
| 7151 | + | |
| 7152 | +[[package]] | |
| 7153 | +name = "vidya-ffi" | |
| 7154 | +version = "0.1.0" | |
| 7155 | +dependencies = [ | |
| 7156 | + "arboard", | |
| 7157 | + "egui", | |
| 7158 | + "egui-winit", | |
| 7159 | + "egui_glow", | |
| 7160 | + "glow", | |
| 7161 | + "glutin", | |
| 7162 | + "glutin-winit", | |
| 7163 | + "jni 0.22.4", | |
| 7164 | + "png 0.17.16", | |
| 7165 | + "vidya", | |
| 7166 | + "winit", | |
| 7167 | +] | |
| 7168 | + | |
| 6043 | 7169 | [[package]] |
| 6044 | 7170 | name = "visibility" |
| 6045 | 7171 | version = "0.1.1" |
| @@ -6166,6 +7292,141 @@ dependencies = [ | ||
| 6166 | 7292 | "wasm-bindgen", |
| 6167 | 7293 | ] |
| 6168 | 7294 | |
| 7295 | +[[package]] | |
| 7296 | +name = "wayland-backend" | |
| 7297 | +version = "0.3.17" | |
| 7298 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 7299 | +checksum = "38a91b4eaddff87b1cd1074985e3713da4af2c49742d1b356b2c01670a67a078" | |
| 7300 | +dependencies = [ | |
| 7301 | + "cc", | |
| 7302 | + "downcast-rs", | |
| 7303 | + "rustix 1.1.4", | |
| 7304 | + "scoped-tls", | |
| 7305 | + "smallvec", | |
| 7306 | + "wayland-sys", | |
| 7307 | +] | |
| 7308 | + | |
| 7309 | +[[package]] | |
| 7310 | +name = "wayland-client" | |
| 7311 | +version = "0.31.15" | |
| 7312 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 7313 | +checksum = "e3c36a0f861ad76d0901f2800b46321410d9f73f2ea88aac0650d86c32688073" | |
| 7314 | +dependencies = [ | |
| 7315 | + "bitflags 2.13.1", | |
| 7316 | + "rustix 1.1.4", | |
| 7317 | + "wayland-backend", | |
| 7318 | + "wayland-scanner", | |
| 7319 | +] | |
| 7320 | + | |
| 7321 | +[[package]] | |
| 7322 | +name = "wayland-csd-frame" | |
| 7323 | +version = "0.3.0" | |
| 7324 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 7325 | +checksum = "625c5029dbd43d25e6aa9615e88b829a5cad13b2819c4ae129fdbb7c31ab4c7e" | |
| 7326 | +dependencies = [ | |
| 7327 | + "bitflags 2.13.1", | |
| 7328 | + "cursor-icon", | |
| 7329 | + "wayland-backend", | |
| 7330 | +] | |
| 7331 | + | |
| 7332 | +[[package]] | |
| 7333 | +name = "wayland-cursor" | |
| 7334 | +version = "0.31.14" | |
| 7335 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 7336 | +checksum = "4a52d18780be9b1314328a3de5f930b73d2200112e3849ca6cb11822793fb34d" | |
| 7337 | +dependencies = [ | |
| 7338 | + "rustix 1.1.4", | |
| 7339 | + "wayland-client", | |
| 7340 | + "xcursor", | |
| 7341 | +] | |
| 7342 | + | |
| 7343 | +[[package]] | |
| 7344 | +name = "wayland-protocols" | |
| 7345 | +version = "0.32.13" | |
| 7346 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 7347 | +checksum = "23d0c813de3daa2ed6520af85a3bd49b0e722a3078506899aa9686fea58dc4b6" | |
| 7348 | +dependencies = [ | |
| 7349 | + "bitflags 2.13.1", | |
| 7350 | + "wayland-backend", | |
| 7351 | + "wayland-client", | |
| 7352 | + "wayland-scanner", | |
| 7353 | +] | |
| 7354 | + | |
| 7355 | +[[package]] | |
| 7356 | +name = "wayland-protocols-experimental" | |
| 7357 | +version = "20250721.0.1" | |
| 7358 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 7359 | +checksum = "40a1f863128dcaaec790d7b4b396cc9b9a7a079e878e18c47e6c2d2c5a8dcbb1" | |
| 7360 | +dependencies = [ | |
| 7361 | + "bitflags 2.13.1", | |
| 7362 | + "wayland-backend", | |
| 7363 | + "wayland-client", | |
| 7364 | + "wayland-protocols", | |
| 7365 | + "wayland-scanner", | |
| 7366 | +] | |
| 7367 | + | |
| 7368 | +[[package]] | |
| 7369 | +name = "wayland-protocols-misc" | |
| 7370 | +version = "0.3.12" | |
| 7371 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 7372 | +checksum = "6e9567599ef23e09b8dad6e429e5738d4509dfc46b3b21f32841a304d16b29c8" | |
| 7373 | +dependencies = [ | |
| 7374 | + "bitflags 2.13.1", | |
| 7375 | + "wayland-backend", | |
| 7376 | + "wayland-client", | |
| 7377 | + "wayland-protocols", | |
| 7378 | + "wayland-scanner", | |
| 7379 | +] | |
| 7380 | + | |
| 7381 | +[[package]] | |
| 7382 | +name = "wayland-protocols-plasma" | |
| 7383 | +version = "0.3.12" | |
| 7384 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 7385 | +checksum = "2b6d8cf1eb2c1c31ed1f5643c88a6e53538129d4af80030c8cabd1f9fa884d91" | |
| 7386 | +dependencies = [ | |
| 7387 | + "bitflags 2.13.1", | |
| 7388 | + "wayland-backend", | |
| 7389 | + "wayland-client", | |
| 7390 | + "wayland-protocols", | |
| 7391 | + "wayland-scanner", | |
| 7392 | +] | |
| 7393 | + | |
| 7394 | +[[package]] | |
| 7395 | +name = "wayland-protocols-wlr" | |
| 7396 | +version = "0.3.12" | |
| 7397 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 7398 | +checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234" | |
| 7399 | +dependencies = [ | |
| 7400 | + "bitflags 2.13.1", | |
| 7401 | + "wayland-backend", | |
| 7402 | + "wayland-client", | |
| 7403 | + "wayland-protocols", | |
| 7404 | + "wayland-scanner", | |
| 7405 | +] | |
| 7406 | + | |
| 7407 | +[[package]] | |
| 7408 | +name = "wayland-scanner" | |
| 7409 | +version = "0.31.11" | |
| 7410 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 7411 | +checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0" | |
| 7412 | +dependencies = [ | |
| 7413 | + "proc-macro2", | |
| 7414 | + "quick-xml", | |
| 7415 | + "quote", | |
| 7416 | +] | |
| 7417 | + | |
| 7418 | +[[package]] | |
| 7419 | +name = "wayland-sys" | |
| 7420 | +version = "0.31.11" | |
| 7421 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 7422 | +checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be" | |
| 7423 | +dependencies = [ | |
| 7424 | + "dlib", | |
| 7425 | + "log", | |
| 7426 | + "once_cell", | |
| 7427 | + "pkg-config", | |
| 7428 | +] | |
| 7429 | + | |
| 6169 | 7430 | [[package]] |
| 6170 | 7431 | name = "web-async" |
| 6171 | 7432 | version = "0.1.5" |
| @@ -6255,6 +7516,22 @@ dependencies = [ | ||
| 6255 | 7516 | "bytes", |
| 6256 | 7517 | ] |
| 6257 | 7518 | |
| 7519 | +[[package]] | |
| 7520 | +name = "webbrowser" | |
| 7521 | +version = "1.2.4" | |
| 7522 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 7523 | +checksum = "62c35be770821a214dbc362fc26908c853e776c0004294d0b10b8a6bad582f94" | |
| 7524 | +dependencies = [ | |
| 7525 | + "jni 0.22.4", | |
| 7526 | + "log", | |
| 7527 | + "ndk-context", | |
| 7528 | + "objc2 0.6.4", | |
| 7529 | + "objc2-app-kit 0.3.2", | |
| 7530 | + "objc2-foundation 0.3.2", | |
| 7531 | + "url", | |
| 7532 | + "web-sys", | |
| 7533 | +] | |
| 7534 | + | |
| 6258 | 7535 | [[package]] |
| 6259 | 7536 | name = "webpki-root-certs" |
| 6260 | 7537 | version = "1.0.9" |
| @@ -6273,6 +7550,12 @@ dependencies = [ | ||
| 6273 | 7550 | "rustls-pki-types", |
| 6274 | 7551 | ] |
| 6275 | 7552 | |
| 7553 | +[[package]] | |
| 7554 | +name = "weezl" | |
| 7555 | +version = "0.1.12" | |
| 7556 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 7557 | +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" | |
| 7558 | + | |
| 6276 | 7559 | [[package]] |
| 6277 | 7560 | name = "wide" |
| 6278 | 7561 | version = "1.5.0" |
| @@ -6607,6 +7890,58 @@ version = "0.52.6" | ||
| 6607 | 7890 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 6608 | 7891 | checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" |
| 6609 | 7892 | |
| 7893 | +[[package]] | |
| 7894 | +name = "winit" | |
| 7895 | +version = "0.30.13" | |
| 7896 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 7897 | +checksum = "a6755fa58a9f8350bd1e472d4c3fcc25f824ec358933bba33306d0b63df5978d" | |
| 7898 | +dependencies = [ | |
| 7899 | + "ahash", | |
| 7900 | + "android-activity", | |
| 7901 | + "atomic-waker", | |
| 7902 | + "bitflags 2.13.1", | |
| 7903 | + "block2 0.5.1", | |
| 7904 | + "bytemuck", | |
| 7905 | + "calloop 0.13.0", | |
| 7906 | + "cfg_aliases 0.2.2", | |
| 7907 | + "concurrent-queue", | |
| 7908 | + "core-foundation 0.9.4", | |
| 7909 | + "core-graphics", | |
| 7910 | + "cursor-icon", | |
| 7911 | + "dpi", | |
| 7912 | + "js-sys", | |
| 7913 | + "libc", | |
| 7914 | + "memmap2", | |
| 7915 | + "ndk", | |
| 7916 | + "objc2 0.5.2", | |
| 7917 | + "objc2-app-kit 0.2.2", | |
| 7918 | + "objc2-foundation 0.2.2", | |
| 7919 | + "objc2-ui-kit", | |
| 7920 | + "orbclient", | |
| 7921 | + "percent-encoding", | |
| 7922 | + "pin-project", | |
| 7923 | + "raw-window-handle", | |
| 7924 | + "redox_syscall 0.4.1", | |
| 7925 | + "rustix 0.38.44", | |
| 7926 | + "sctk-adwaita", | |
| 7927 | + "smithay-client-toolkit 0.19.2", | |
| 7928 | + "smol_str", | |
| 7929 | + "tracing", | |
| 7930 | + "unicode-segmentation", | |
| 7931 | + "wasm-bindgen", | |
| 7932 | + "wasm-bindgen-futures", | |
| 7933 | + "wayland-backend", | |
| 7934 | + "wayland-client", | |
| 7935 | + "wayland-protocols", | |
| 7936 | + "wayland-protocols-plasma", | |
| 7937 | + "web-sys", | |
| 7938 | + "web-time", | |
| 7939 | + "windows-sys 0.52.0", | |
| 7940 | + "x11-dl", | |
| 7941 | + "x11rb", | |
| 7942 | + "xkbcommon-dl", | |
| 7943 | +] | |
| 7944 | + | |
| 6610 | 7945 | [[package]] |
| 6611 | 7946 | name = "winnow" |
| 6612 | 7947 | version = "1.0.4" |
| @@ -6662,6 +7997,38 @@ dependencies = [ | ||
| 6662 | 7997 | "web-sys", |
| 6663 | 7998 | ] |
| 6664 | 7999 | |
| 8000 | +[[package]] | |
| 8001 | +name = "x11-dl" | |
| 8002 | +version = "2.21.0" | |
| 8003 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 8004 | +checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" | |
| 8005 | +dependencies = [ | |
| 8006 | + "libc", | |
| 8007 | + "once_cell", | |
| 8008 | + "pkg-config", | |
| 8009 | +] | |
| 8010 | + | |
| 8011 | +[[package]] | |
| 8012 | +name = "x11rb" | |
| 8013 | +version = "0.13.2" | |
| 8014 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 8015 | +checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" | |
| 8016 | +dependencies = [ | |
| 8017 | + "as-raw-xcb-connection", | |
| 8018 | + "gethostname", | |
| 8019 | + "libc", | |
| 8020 | + "libloading", | |
| 8021 | + "once_cell", | |
| 8022 | + "rustix 1.1.4", | |
| 8023 | + "x11rb-protocol", | |
| 8024 | +] | |
| 8025 | + | |
| 8026 | +[[package]] | |
| 8027 | +name = "x11rb-protocol" | |
| 8028 | +version = "0.13.2" | |
| 8029 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 8030 | +checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" | |
| 8031 | + | |
| 6665 | 8032 | [[package]] |
| 6666 | 8033 | name = "x509-parser" |
| 6667 | 8034 | version = "0.18.1" |
| @@ -6680,6 +8047,31 @@ dependencies = [ | ||
| 6680 | 8047 | "time", |
| 6681 | 8048 | ] |
| 6682 | 8049 | |
| 8050 | +[[package]] | |
| 8051 | +name = "xcursor" | |
| 8052 | +version = "0.3.11" | |
| 8053 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 8054 | +checksum = "163b33ed8786455e2fa5d72f554057ce3f3182425434f756cd39c99839d88e23" | |
| 8055 | + | |
| 8056 | +[[package]] | |
| 8057 | +name = "xkbcommon-dl" | |
| 8058 | +version = "0.4.2" | |
| 8059 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 8060 | +checksum = "d039de8032a9a8856a6be89cea3e5d12fdd82306ab7c94d74e6deab2460651c5" | |
| 8061 | +dependencies = [ | |
| 8062 | + "bitflags 2.13.1", | |
| 8063 | + "dlib", | |
| 8064 | + "log", | |
| 8065 | + "once_cell", | |
| 8066 | + "xkeysym", | |
| 8067 | +] | |
| 8068 | + | |
| 8069 | +[[package]] | |
| 8070 | +name = "xkeysym" | |
| 8071 | +version = "0.2.1" | |
| 8072 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 8073 | +checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" | |
| 8074 | + | |
| 6683 | 8075 | [[package]] |
| 6684 | 8076 | name = "xml-rs" |
| 6685 | 8077 | version = "0.8.28" |
| @@ -6837,12 +8229,47 @@ dependencies = [ | ||
| 6837 | 8229 | "syn 2.0.119", |
| 6838 | 8230 | ] |
| 6839 | 8231 | |
| 8232 | +[[package]] | |
| 8233 | +name = "zip" | |
| 8234 | +version = "2.4.2" | |
| 8235 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 8236 | +checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50" | |
| 8237 | +dependencies = [ | |
| 8238 | + "arbitrary", | |
| 8239 | + "crc32fast", | |
| 8240 | + "crossbeam-utils", | |
| 8241 | + "displaydoc", | |
| 8242 | + "flate2", | |
| 8243 | + "indexmap 2.14.0", | |
| 8244 | + "memchr", | |
| 8245 | + "thiserror 2.0.19", | |
| 8246 | + "zopfli", | |
| 8247 | +] | |
| 8248 | + | |
| 8249 | +[[package]] | |
| 8250 | +name = "zlib-rs" | |
| 8251 | +version = "0.6.7" | |
| 8252 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 8253 | +checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12" | |
| 8254 | + | |
| 6840 | 8255 | [[package]] |
| 6841 | 8256 | name = "zmij" |
| 6842 | 8257 | version = "1.0.23" |
| 6843 | 8258 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 6844 | 8259 | checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" |
| 6845 | 8260 | |
| 8261 | +[[package]] | |
| 8262 | +name = "zopfli" | |
| 8263 | +version = "0.8.3" | |
| 8264 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 8265 | +checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" | |
| 8266 | +dependencies = [ | |
| 8267 | + "bumpalo", | |
| 8268 | + "crc32fast", | |
| 8269 | + "log", | |
| 8270 | + "simd-adler32", | |
| 8271 | +] | |
| 8272 | + | |
| 6846 | 8273 | [[package]] |
| 6847 | 8274 | name = "zune-core" |
| 6848 | 8275 | version = "0.5.1" |
| @@ -2,6 +2,22 @@ | |||
| 2 | # It is not intended for manual editing. | 2 | # It is not intended for manual editing. |
| 3 | version = 4 | 3 | version = 4 |
| 4 | 4 | ||
| 5 | +[[package]] | ||
| 6 | +name = "ab_glyph" | ||
| 7 | +version = "0.2.32" | ||
| 8 | +source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 9 | +checksum = "01c0457472c38ea5bd1c3b5ada5e368271cb550be7a4ca4a0b4634e9913f6cc2" | ||
| 10 | +dependencies = [ | ||
| 11 | + "ab_glyph_rasterizer", | ||
| 12 | + "owned_ttf_parser", | ||
| 13 | +] | ||
| 14 | + | ||
| 15 | +[[package]] | ||
| 16 | +name = "ab_glyph_rasterizer" | ||
| 17 | +version = "0.1.10" | ||
| 18 | +source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 19 | +checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" | ||
| 20 | + | ||
| 5 | [[package]] | 21 | [[package]] |
| 6 | name = "addr2line" | 22 | name = "addr2line" |
| 7 | version = "0.25.1" | 23 | version = "0.25.1" |
| @@ -52,6 +68,19 @@ dependencies = [ | |||
| 52 | "subtle", | 68 | "subtle", |
| 53 | ] | 69 | ] |
| 54 | 70 | ||
| 71 | +[[package]] | ||
| 72 | +name = "ahash" | ||
| 73 | +version = "0.8.12" | ||
| 74 | +source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 75 | +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" | ||
| 76 | +dependencies = [ | ||
| 77 | + "cfg-if", | ||
| 78 | + "getrandom 0.3.4", | ||
| 79 | + "once_cell", | ||
| 80 | + "version_check", | ||
| 81 | + "zerocopy", | ||
| 82 | +] | ||
| 83 | + | ||
| 55 | [[package]] | 84 | [[package]] |
| 56 | name = "aho-corasick" | 85 | name = "aho-corasick" |
| 57 | version = "1.1.4" | 86 | version = "1.1.4" |
| @@ -89,6 +118,31 @@ dependencies = [ | |||
| 89 | "pkg-config", | 118 | "pkg-config", |
| 90 | ] | 119 | ] |
| 91 | 120 | ||
| 121 | +[[package]] | ||
| 122 | +name = "android-activity" | ||
| 123 | +version = "0.6.1" | ||
| 124 | +source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 125 | +checksum = "0f2a1bb052857d5dd49572219344a7332b31b76405648eabac5bc68978251bcd" | ||
| 126 | +dependencies = [ | ||
| 127 | + "android-properties", | ||
| 128 | + "bitflags 2.13.1", | ||
| 129 | + "cc", | ||
| 130 | + "jni 0.22.4", | ||
| 131 | + "libc", | ||
| 132 | + "log", | ||
| 133 | + "ndk", | ||
| 134 | + "ndk-context", | ||
| 135 | + "ndk-sys", | ||
| 136 | + "num_enum", | ||
| 137 | + "thiserror 2.0.19", | ||
| 138 | +] | ||
| 139 | + | ||
| 140 | +[[package]] | ||
| 141 | +name = "android-properties" | ||
| 142 | +version = "0.2.2" | ||
| 143 | +source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 144 | +checksum = "fc7eb209b1518d6bb87b283c20095f5228ecda460da70b44f0802523dea6da04" | ||
| 145 | + | ||
| 92 | [[package]] | 146 | [[package]] |
| 93 | name = "android_system_properties" | 147 | name = "android_system_properties" |
| 94 | version = "0.1.5" | 148 | version = "0.1.5" |
| @@ -188,6 +242,35 @@ version = "1.0.104" | |||
| 188 | source = "registry+https://github.com/rust-lang/crates.io-index" | 242 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 189 | checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" | 243 | checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" |
| 190 | 244 | ||
| 245 | +[[package]] | ||
| 246 | +name = "arbitrary" | ||
| 247 | +version = "1.4.2" | ||
| 248 | +source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 249 | +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" | ||
| 250 | +dependencies = [ | ||
| 251 | + "derive_arbitrary", | ||
| 252 | +] | ||
| 253 | + | ||
| 254 | +[[package]] | ||
| 255 | +name = "arboard" | ||
| 256 | +version = "3.6.1" | ||
| 257 | +source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 258 | +checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf" | ||
| 259 | +dependencies = [ | ||
| 260 | + "clipboard-win", | ||
| 261 | + "image", | ||
| 262 | + "log", | ||
| 263 | + "objc2 0.6.4", | ||
| 264 | + "objc2-app-kit 0.3.2", | ||
| 265 | + "objc2-core-foundation", | ||
| 266 | + "objc2-core-graphics", | ||
| 267 | + "objc2-foundation 0.3.2", | ||
| 268 | + "parking_lot", | ||
| 269 | + "percent-encoding", | ||
| 270 | + "windows-sys 0.59.0", | ||
| 271 | + "x11rb", | ||
| 272 | +] | ||
| 273 | + | ||
| 191 | [[package]] | 274 | [[package]] |
| 192 | name = "archery" | 275 | name = "archery" |
| 193 | version = "1.2.2" | 276 | version = "1.2.2" |
| @@ -209,6 +292,12 @@ version = "0.7.8" | |||
| 209 | source = "registry+https://github.com/rust-lang/crates.io-index" | 292 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 210 | checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" | 293 | checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" |
| 211 | 294 | ||
| 295 | +[[package]] | ||
| 296 | +name = "as-raw-xcb-connection" | ||
| 297 | +version = "1.0.1" | ||
| 298 | +source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 299 | +checksum = "175571dd1d178ced59193a6fc02dde1b972eb0bc56c892cde9beeceac5bf0f6b" | ||
| 300 | + | ||
| 212 | [[package]] | 301 | [[package]] |
| 213 | name = "asn1-rs" | 302 | name = "asn1-rs" |
| 214 | version = "0.7.2" | 303 | version = "0.7.2" |
| @@ -385,7 +474,7 @@ dependencies = [ | |||
| 385 | "addr2line", | 474 | "addr2line", |
| 386 | "cfg-if", | 475 | "cfg-if", |
| 387 | "libc", | 476 | "libc", |
| 388 | - "miniz_oxide", | 477 | + "miniz_oxide 0.8.9", |
| 389 | "object", | 478 | "object", |
| 390 | "rustc-demangle", | 479 | "rustc-demangle", |
| 391 | "windows-link", | 480 | "windows-link", |
| @@ -501,13 +590,22 @@ dependencies = [ | |||
| 501 | "hybrid-array", | 590 | "hybrid-array", |
| 502 | ] | 591 | ] |
| 503 | 592 | ||
| 593 | +[[package]] | ||
| 594 | +name = "block2" | ||
| 595 | +version = "0.5.1" | ||
| 596 | +source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 597 | +checksum = "2c132eebf10f5cad5289222520a4a058514204aed6d791f1cf4fe8088b82d15f" | ||
| 598 | +dependencies = [ | ||
| 599 | + "objc2 0.5.2", | ||
| 600 | +] | ||
| 601 | + | ||
| 504 | [[package]] | 602 | [[package]] |
| 505 | name = "block2" | 603 | name = "block2" |
| 506 | version = "0.6.2" | 604 | version = "0.6.2" |
| 507 | source = "registry+https://github.com/rust-lang/crates.io-index" | 605 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 508 | checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" | 606 | checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" |
| 509 | dependencies = [ | 607 | dependencies = [ |
| 510 | - "objc2", | 608 | + "objc2 0.6.4", |
| 511 | ] | 609 | ] |
| 512 | 610 | ||
| 513 | [[package]] | 611 | [[package]] |
| @@ -575,6 +673,57 @@ dependencies = [ | |||
| 575 | "serde", | 673 | "serde", |
| 576 | ] | 674 | ] |
| 577 | 675 | ||
| 676 | +[[package]] | ||
| 677 | +name = "calloop" | ||
| 678 | +version = "0.13.0" | ||
| 679 | +source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 680 | +checksum = "b99da2f8558ca23c71f4fd15dc57c906239752dd27ff3c00a1d56b685b7cbfec" | ||
| 681 | +dependencies = [ | ||
| 682 | + "bitflags 2.13.1", | ||
| 683 | + "log", | ||
| 684 | + "polling", | ||
| 685 | + "rustix 0.38.44", | ||
| 686 | + "slab", | ||
| 687 | + "thiserror 1.0.69", | ||
| 688 | +] | ||
| 689 | + | ||
| 690 | +[[package]] | ||
| 691 | +name = "calloop" | ||
| 692 | +version = "0.14.4" | ||
| 693 | +source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 694 | +checksum = "4dbf9978365bac10f54d1d4b04f7ce4427e51f71d61f2fe15e3fed5166474df7" | ||
| 695 | +dependencies = [ | ||
| 696 | + "bitflags 2.13.1", | ||
| 697 | + "polling", | ||
| 698 | + "rustix 1.1.4", | ||
| 699 | + "slab", | ||
| 700 | + "tracing", | ||
| 701 | +] | ||
| 702 | + | ||
| 703 | +[[package]] | ||
| 704 | +name = "calloop-wayland-source" | ||
| 705 | +version = "0.3.0" | ||
| 706 | +source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 707 | +checksum = "95a66a987056935f7efce4ab5668920b5d0dac4a7c99991a67395f13702ddd20" | ||
| 708 | +dependencies = [ | ||
| 709 | + "calloop 0.13.0", | ||
| 710 | + "rustix 0.38.44", | ||
| 711 | + "wayland-backend", | ||
| 712 | + "wayland-client", | ||
| 713 | +] | ||
| 714 | + | ||
| 715 | +[[package]] | ||
| 716 | +name = "calloop-wayland-source" | ||
| 717 | +version = "0.4.1" | ||
| 718 | +source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 719 | +checksum = "138efcf0940a02ebf0cc8d1eff41a1682a46b431630f4c52450d6265876021fa" | ||
| 720 | +dependencies = [ | ||
| 721 | + "calloop 0.14.4", | ||
| 722 | + "rustix 1.1.4", | ||
| 723 | + "wayland-backend", | ||
| 724 | + "wayland-client", | ||
| 725 | +] | ||
| 726 | + | ||
| 578 | [[package]] | 727 | [[package]] |
| 579 | name = "cc" | 728 | name = "cc" |
| 580 | version = "1.4.0" | 729 | version = "1.4.0" |
| @@ -630,6 +779,15 @@ version = "0.2.2" | |||
| 630 | source = "registry+https://github.com/rust-lang/crates.io-index" | 779 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 631 | checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" | 780 | checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" |
| 632 | 781 | ||
| 782 | +[[package]] | ||
| 783 | +name = "cgl" | ||
| 784 | +version = "0.3.2" | ||
| 785 | +source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 786 | +checksum = "0ced0551234e87afee12411d535648dd89d2e7f34c78b753395567aff3d447ff" | ||
| 787 | +dependencies = [ | ||
| 788 | + "libc", | ||
| 789 | +] | ||
| 790 | + | ||
| 633 | [[package]] | 791 | [[package]] |
| 634 | name = "chacha20" | 792 | name = "chacha20" |
| 635 | version = "0.10.1" | 793 | version = "0.10.1" |
| @@ -714,6 +872,15 @@ version = "0.7.7" | |||
| 714 | source = "registry+https://github.com/rust-lang/crates.io-index" | 872 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 715 | checksum = "c3e64b0cc0439b12df2fa678eae89a1c56a529fd067a9115f7827f1fffd22b32" | 873 | checksum = "c3e64b0cc0439b12df2fa678eae89a1c56a529fd067a9115f7827f1fffd22b32" |
| 716 | 874 | ||
| 875 | +[[package]] | ||
| 876 | +name = "clipboard-win" | ||
| 877 | +version = "5.4.1" | ||
| 878 | +source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 879 | +checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4" | ||
| 880 | +dependencies = [ | ||
| 881 | + "error-code", | ||
| 882 | +] | ||
| 883 | + | ||
| 717 | [[package]] | 884 | [[package]] |
| 718 | name = "cmake" | 885 | name = "cmake" |
| 719 | version = "0.1.58" | 886 | version = "0.1.58" |
| @@ -766,6 +933,15 @@ dependencies = [ | |||
| 766 | "memchr", | 933 | "memchr", |
| 767 | ] | 934 | ] |
| 768 | 935 | ||
| 936 | +[[package]] | ||
| 937 | +name = "concurrent-queue" | ||
| 938 | +version = "2.5.0" | ||
| 939 | +source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 940 | +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" | ||
| 941 | +dependencies = [ | ||
| 942 | + "crossbeam-utils", | ||
| 943 | +] | ||
| 944 | + | ||
| 769 | [[package]] | 945 | [[package]] |
| 770 | name = "conducer" | 946 | name = "conducer" |
| 771 | version = "0.3.1" | 947 | version = "0.3.1" |
| @@ -817,6 +993,16 @@ dependencies = [ | |||
| 817 | "tracing", | 993 | "tracing", |
| 818 | ] | 994 | ] |
| 819 | 995 | ||
| 996 | +[[package]] | ||
| 997 | +name = "core-foundation" | ||
| 998 | +version = "0.9.4" | ||
| 999 | +source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1000 | +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" | ||
| 1001 | +dependencies = [ | ||
| 1002 | + "core-foundation-sys", | ||
| 1003 | + "libc", | ||
| 1004 | +] | ||
| 1005 | + | ||
| 820 | [[package]] | 1006 | [[package]] |
| 821 | name = "core-foundation" | 1007 | name = "core-foundation" |
| 822 | version = "0.10.1" | 1008 | version = "0.10.1" |
| @@ -833,6 +1019,30 @@ version = "0.8.7" | |||
| 833 | source = "registry+https://github.com/rust-lang/crates.io-index" | 1019 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 834 | checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" | 1020 | checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" |
| 835 | 1021 | ||
| 1022 | +[[package]] | ||
| 1023 | +name = "core-graphics" | ||
| 1024 | +version = "0.23.2" | ||
| 1025 | +source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1026 | +checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081" | ||
| 1027 | +dependencies = [ | ||
| 1028 | + "bitflags 1.3.2", | ||
| 1029 | + "core-foundation 0.9.4", | ||
| 1030 | + "core-graphics-types", | ||
| 1031 | + "foreign-types", | ||
| 1032 | + "libc", | ||
| 1033 | +] | ||
| 1034 | + | ||
| 1035 | +[[package]] | ||
| 1036 | +name = "core-graphics-types" | ||
| 1037 | +version = "0.1.3" | ||
| 1038 | +source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1039 | +checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf" | ||
| 1040 | +dependencies = [ | ||
| 1041 | + "bitflags 1.3.2", | ||
| 1042 | + "core-foundation 0.9.4", | ||
| 1043 | + "libc", | ||
| 1044 | +] | ||
| 1045 | + | ||
| 836 | [[package]] | 1046 | [[package]] |
| 837 | name = "core_affinity" | 1047 | name = "core_affinity" |
| 838 | version = "0.8.3" | 1048 | version = "0.8.3" |
| @@ -873,13 +1083,13 @@ dependencies = [ | |||
| 873 | "ndk-context", | 1083 | "ndk-context", |
| 874 | "num-derive", | 1084 | "num-derive", |
| 875 | "num-traits", | 1085 | "num-traits", |
| 876 | - "objc2", | 1086 | + "objc2 0.6.4", |
| 877 | "objc2-audio-toolbox", | 1087 | "objc2-audio-toolbox", |
| 878 | "objc2-avf-audio", | 1088 | "objc2-avf-audio", |
| 879 | "objc2-core-audio", | 1089 | "objc2-core-audio", |
| 880 | "objc2-core-audio-types", | 1090 | "objc2-core-audio-types", |
| 881 | "objc2-core-foundation", | 1091 | "objc2-core-foundation", |
| 882 | - "objc2-foundation", | 1092 | + "objc2-foundation 0.3.2", |
| 883 | "pipewire", | 1093 | "pipewire", |
| 884 | "wasm-bindgen", | 1094 | "wasm-bindgen", |
| 885 | "wasm-bindgen-futures", | 1095 | "wasm-bindgen-futures", |
| @@ -905,6 +1115,15 @@ dependencies = [ | |||
| 905 | "libc", | 1115 | "libc", |
| 906 | ] | 1116 | ] |
| 907 | 1117 | ||
| 1118 | +[[package]] | ||
| 1119 | +name = "crc32fast" | ||
| 1120 | +version = "1.5.1" | ||
| 1121 | +source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1122 | +checksum = "8498c871161e1742aaa9d52551b2d6ebdd4c3d45a3be423e3728f33b955be550" | ||
| 1123 | +dependencies = [ | ||
| 1124 | + "cfg-if", | ||
| 1125 | +] | ||
| 1126 | + | ||
| 908 | [[package]] | 1127 | [[package]] |
| 909 | name = "critical-section" | 1128 | name = "critical-section" |
| 910 | version = "1.2.0" | 1129 | version = "1.2.0" |
| @@ -988,6 +1207,12 @@ dependencies = [ | |||
| 988 | "cmov", | 1207 | "cmov", |
| 989 | ] | 1208 | ] |
| 990 | 1209 | ||
| 1210 | +[[package]] | ||
| 1211 | +name = "cursor-icon" | ||
| 1212 | +version = "1.2.0" | ||
| 1213 | +source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1214 | +checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" | ||
| 1215 | + | ||
| 991 | [[package]] | 1216 | [[package]] |
| 992 | name = "curve25519-dalek" | 1217 | name = "curve25519-dalek" |
| 993 | version = "5.0.0-pre.1" | 1218 | version = "5.0.0-pre.1" |
| @@ -1163,6 +1388,17 @@ dependencies = [ | |||
| 1163 | "serde_core", | 1388 | "serde_core", |
| 1164 | ] | 1389 | ] |
| 1165 | 1390 | ||
| 1391 | +[[package]] | ||
| 1392 | +name = "derive_arbitrary" | ||
| 1393 | +version = "1.4.2" | ||
| 1394 | +source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1395 | +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" | ||
| 1396 | +dependencies = [ | ||
| 1397 | + "proc-macro2", | ||
| 1398 | + "quote", | ||
| 1399 | + "syn 2.0.119", | ||
| 1400 | +] | ||
| 1401 | + | ||
| 1166 | [[package]] | 1402 | [[package]] |
| 1167 | name = "derive_builder" | 1403 | name = "derive_builder" |
| 1168 | version = "0.20.2" | 1404 | version = "0.20.2" |
| @@ -1244,6 +1480,12 @@ dependencies = [ | |||
| 1244 | "crypto-common 0.2.2", | 1480 | "crypto-common 0.2.2", |
| 1245 | ] | 1481 | ] |
| 1246 | 1482 | ||
| 1483 | +[[package]] | ||
| 1484 | +name = "dispatch" | ||
| 1485 | +version = "0.2.0" | ||
| 1486 | +source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1487 | +checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b" | ||
| 1488 | + | ||
| 1247 | [[package]] | 1489 | [[package]] |
| 1248 | name = "dispatch2" | 1490 | name = "dispatch2" |
| 1249 | version = "0.3.1" | 1491 | version = "0.3.1" |
| @@ -1251,9 +1493,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| 1251 | checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" | 1493 | checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" |
| 1252 | dependencies = [ | 1494 | dependencies = [ |
| 1253 | "bitflags 2.13.1", | 1495 | "bitflags 2.13.1", |
| 1254 | - "block2", | 1496 | + "block2 0.6.2", |
| 1255 | "libc", | 1497 | "libc", |
| 1256 | - "objc2", | 1498 | + "objc2 0.6.4", |
| 1257 | ] | 1499 | ] |
| 1258 | 1500 | ||
| 1259 | [[package]] | 1501 | [[package]] |
| @@ -1267,6 +1509,15 @@ dependencies = [ | |||
| 1267 | "syn 3.0.3", | 1509 | "syn 3.0.3", |
| 1268 | ] | 1510 | ] |
| 1269 | 1511 | ||
| 1512 | +[[package]] | ||
| 1513 | +name = "dlib" | ||
| 1514 | +version = "0.5.3" | ||
| 1515 | +source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1516 | +checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a" | ||
| 1517 | +dependencies = [ | ||
| 1518 | + "libloading", | ||
| 1519 | +] | ||
| 1520 | + | ||
| 1270 | [[package]] | 1521 | [[package]] |
| 1271 | name = "dlopen2" | 1522 | name = "dlopen2" |
| 1272 | version = "0.5.0" | 1523 | version = "0.5.0" |
| @@ -1287,6 +1538,18 @@ dependencies = [ | |||
| 1287 | "litrs", | 1538 | "litrs", |
| 1288 | ] | 1539 | ] |
| 1289 | 1540 | ||
| 1541 | +[[package]] | ||
| 1542 | +name = "downcast-rs" | ||
| 1543 | +version = "1.2.1" | ||
| 1544 | +source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1545 | +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" | ||
| 1546 | + | ||
| 1547 | +[[package]] | ||
| 1548 | +name = "dpi" | ||
| 1549 | +version = "0.1.2" | ||
| 1550 | +source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1551 | +checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" | ||
| 1552 | + | ||
| 1290 | [[package]] | 1553 | [[package]] |
| 1291 | name = "dunce" | 1554 | name = "dunce" |
| 1292 | version = "1.0.5" | 1555 | version = "1.0.5" |
| @@ -1299,6 +1562,16 @@ version = "1.0.20" | |||
| 1299 | source = "registry+https://github.com/rust-lang/crates.io-index" | 1562 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 1300 | checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" | 1563 | checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" |
| 1301 | 1564 | ||
| 1565 | +[[package]] | ||
| 1566 | +name = "ecolor" | ||
| 1567 | +version = "0.31.1" | ||
| 1568 | +source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1569 | +checksum = "bc4feb366740ded31a004a0e4452fbf84e80ef432ecf8314c485210229672fd1" | ||
| 1570 | +dependencies = [ | ||
| 1571 | + "bytemuck", | ||
| 1572 | + "emath", | ||
| 1573 | +] | ||
| 1574 | + | ||
| 1302 | [[package]] | 1575 | [[package]] |
| 1303 | name = "ed25519" | 1576 | name = "ed25519" |
| 1304 | version = "3.0.0-rc.4" | 1577 | version = "3.0.0-rc.4" |
| @@ -1326,12 +1599,72 @@ dependencies = [ | |||
| 1326 | "zeroize", | 1599 | "zeroize", |
| 1327 | ] | 1600 | ] |
| 1328 | 1601 | ||
| 1602 | +[[package]] | ||
| 1603 | +name = "egui" | ||
| 1604 | +version = "0.31.1" | ||
| 1605 | +source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1606 | +checksum = "25dd34cec49ab55d85ebf70139cb1ccd29c977ef6b6ba4fe85489d6877ee9ef3" | ||
| 1607 | +dependencies = [ | ||
| 1608 | + "ahash", | ||
| 1609 | + "bitflags 2.13.1", | ||
| 1610 | + "emath", | ||
| 1611 | + "epaint", | ||
| 1612 | + "log", | ||
| 1613 | + "nohash-hasher", | ||
| 1614 | + "profiling", | ||
| 1615 | +] | ||
| 1616 | + | ||
| 1617 | +[[package]] | ||
| 1618 | +name = "egui-winit" | ||
| 1619 | +version = "0.31.1" | ||
| 1620 | +source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1621 | +checksum = "7d9dfbb78fe4eb9c3a39ad528b90ee5915c252e77bbab9d4ebc576541ab67e13" | ||
| 1622 | +dependencies = [ | ||
| 1623 | + "ahash", | ||
| 1624 | + "arboard", | ||
| 1625 | + "bytemuck", | ||
| 1626 | + "egui", | ||
| 1627 | + "log", | ||
| 1628 | + "profiling", | ||
| 1629 | + "raw-window-handle", | ||
| 1630 | + "smithay-clipboard", | ||
| 1631 | + "web-time", | ||
| 1632 | + "webbrowser", | ||
| 1633 | + "winit", | ||
| 1634 | +] | ||
| 1635 | + | ||
| 1636 | +[[package]] | ||
| 1637 | +name = "egui_glow" | ||
| 1638 | +version = "0.31.1" | ||
| 1639 | +source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1640 | +checksum = "910906e3f042ea6d2378ec12a6fd07698e14ddae68aed2d819ffe944a73aab9e" | ||
| 1641 | +dependencies = [ | ||
| 1642 | + "ahash", | ||
| 1643 | + "bytemuck", | ||
| 1644 | + "egui", | ||
| 1645 | + "glow", | ||
| 1646 | + "log", | ||
| 1647 | + "memoffset", | ||
| 1648 | + "profiling", | ||
| 1649 | + "wasm-bindgen", | ||
| 1650 | + "web-sys", | ||
| 1651 | +] | ||
| 1652 | + | ||
| 1329 | [[package]] | 1653 | [[package]] |
| 1330 | name = "either" | 1654 | name = "either" |
| 1331 | version = "1.17.0" | 1655 | version = "1.17.0" |
| 1332 | source = "registry+https://github.com/rust-lang/crates.io-index" | 1656 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 1333 | checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d" | 1657 | checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d" |
| 1334 | 1658 | ||
| 1659 | +[[package]] | ||
| 1660 | +name = "emath" | ||
| 1661 | +version = "0.31.1" | ||
| 1662 | +source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1663 | +checksum = "9e4cadcff7a5353ba72b7fea76bf2122b5ebdbc68e8155aa56dfdea90083fe1b" | ||
| 1664 | +dependencies = [ | ||
| 1665 | + "bytemuck", | ||
| 1666 | +] | ||
| 1667 | + | ||
| 1335 | [[package]] | 1668 | [[package]] |
| 1336 | name = "embedded-io" | 1669 | name = "embedded-io" |
| 1337 | version = "0.4.0" | 1670 | version = "0.4.0" |
| @@ -1410,6 +1743,30 @@ dependencies = [ | |||
| 1410 | "log", | 1743 | "log", |
| 1411 | ] | 1744 | ] |
| 1412 | 1745 | ||
| 1746 | +[[package]] | ||
| 1747 | +name = "epaint" | ||
| 1748 | +version = "0.31.1" | ||
| 1749 | +source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1750 | +checksum = "41fcc0f5a7c613afd2dee5e4b30c3e6acafb8ad6f0edb06068811f708a67c562" | ||
| 1751 | +dependencies = [ | ||
| 1752 | + "ab_glyph", | ||
| 1753 | + "ahash", | ||
| 1754 | + "bytemuck", | ||
| 1755 | + "ecolor", | ||
| 1756 | + "emath", | ||
| 1757 | + "epaint_default_fonts", | ||
| 1758 | + "log", | ||
| 1759 | + "nohash-hasher", | ||
| 1760 | + "parking_lot", | ||
| 1761 | + "profiling", | ||
| 1762 | +] | ||
| 1763 | + | ||
| 1764 | +[[package]] | ||
| 1765 | +name = "epaint_default_fonts" | ||
| 1766 | +version = "0.31.1" | ||
| 1767 | +source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1768 | +checksum = "fc7e7a64c02cf7a5b51e745a9e45f60660a286f151c238b9d397b3e923f5082f" | ||
| 1769 | + | ||
| 1413 | [[package]] | 1770 | [[package]] |
| 1414 | name = "equivalent" | 1771 | name = "equivalent" |
| 1415 | version = "1.0.2" | 1772 | version = "1.0.2" |
| @@ -1426,6 +1783,12 @@ dependencies = [ | |||
| 1426 | "windows-sys 0.61.2", | 1783 | "windows-sys 0.61.2", |
| 1427 | ] | 1784 | ] |
| 1428 | 1785 | ||
| 1786 | +[[package]] | ||
| 1787 | +name = "error-code" | ||
| 1788 | +version = "3.4.0" | ||
| 1789 | +source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1790 | +checksum = "0b5343afd4a8365a643ac588dab4cf234a190c7f6c88c9f6dd6ffe00837661b7" | ||
| 1791 | + | ||
| 1429 | [[package]] | 1792 | [[package]] |
| 1430 | name = "erydanos" | 1793 | name = "erydanos" |
| 1431 | version = "0.2.18" | 1794 | version = "0.2.18" |
| @@ -1465,6 +1828,21 @@ version = "2.5.0" | |||
| 1465 | source = "registry+https://github.com/rust-lang/crates.io-index" | 1828 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 1466 | checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" | 1829 | checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" |
| 1467 | 1830 | ||
| 1831 | +[[package]] | ||
| 1832 | +name = "fax" | ||
| 1833 | +version = "0.2.7" | ||
| 1834 | +source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1835 | +checksum = "caf1079563223d5d59d83c85886a56e586cfd5c1a26292e971a0fa266531ac5a" | ||
| 1836 | + | ||
| 1837 | +[[package]] | ||
| 1838 | +name = "fdeflate" | ||
| 1839 | +version = "0.3.7" | ||
| 1840 | +source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1841 | +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" | ||
| 1842 | +dependencies = [ | ||
| 1843 | + "simd-adler32", | ||
| 1844 | +] | ||
| 1845 | + | ||
| 1468 | [[package]] | 1846 | [[package]] |
| 1469 | name = "fiat-crypto" | 1847 | name = "fiat-crypto" |
| 1470 | version = "0.3.0" | 1848 | version = "0.3.0" |
| @@ -1501,6 +1879,17 @@ version = "0.5.7" | |||
| 1501 | source = "registry+https://github.com/rust-lang/crates.io-index" | 1879 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 1502 | checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" | 1880 | checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" |
| 1503 | 1881 | ||
| 1882 | +[[package]] | ||
| 1883 | +name = "flate2" | ||
| 1884 | +version = "1.1.10" | ||
| 1885 | +source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1886 | +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" | ||
| 1887 | +dependencies = [ | ||
| 1888 | + "crc32fast", | ||
| 1889 | + "miniz_oxide 0.9.1", | ||
| 1890 | + "zlib-rs", | ||
| 1891 | +] | ||
| 1892 | + | ||
| 1504 | [[package]] | 1893 | [[package]] |
| 1505 | name = "fnv" | 1894 | name = "fnv" |
| 1506 | version = "1.0.7" | 1895 | version = "1.0.7" |
| @@ -1513,6 +1902,33 @@ version = "0.2.0" | |||
| 1513 | source = "registry+https://github.com/rust-lang/crates.io-index" | 1902 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 1514 | checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" | 1903 | checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" |
| 1515 | 1904 | ||
| 1905 | +[[package]] | ||
| 1906 | +name = "foreign-types" | ||
| 1907 | +version = "0.5.0" | ||
| 1908 | +source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1909 | +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" | ||
| 1910 | +dependencies = [ | ||
| 1911 | + "foreign-types-macros", | ||
| 1912 | + "foreign-types-shared", | ||
| 1913 | +] | ||
| 1914 | + | ||
| 1915 | +[[package]] | ||
| 1916 | +name = "foreign-types-macros" | ||
| 1917 | +version = "0.2.4" | ||
| 1918 | +source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1919 | +checksum = "ea5190182e6915eb873ddbc16e23b711b6eb1f9c00a0d0a3a91b5f6228475225" | ||
| 1920 | +dependencies = [ | ||
| 1921 | + "proc-macro2", | ||
| 1922 | + "quote", | ||
| 1923 | + "syn 3.0.3", | ||
| 1924 | +] | ||
| 1925 | + | ||
| 1926 | +[[package]] | ||
| 1927 | +name = "foreign-types-shared" | ||
| 1928 | +version = "0.3.1" | ||
| 1929 | +source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1930 | +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" | ||
| 1931 | + | ||
| 1516 | [[package]] | 1932 | [[package]] |
| 1517 | name = "form_urlencoded" | 1933 | name = "form_urlencoded" |
| 1518 | version = "1.2.2" | 1934 | version = "1.2.2" |
| @@ -1680,6 +2096,16 @@ dependencies = [ | |||
| 1680 | "version_check", | 2096 | "version_check", |
| 1681 | ] | 2097 | ] |
| 1682 | 2098 | ||
| 2099 | +[[package]] | ||
| 2100 | +name = "gethostname" | ||
| 2101 | +version = "1.1.0" | ||
| 2102 | +source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2103 | +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" | ||
| 2104 | +dependencies = [ | ||
| 2105 | + "rustix 1.1.4", | ||
| 2106 | + "windows-link", | ||
| 2107 | +] | ||
| 2108 | + | ||
| 1683 | [[package]] | 2109 | [[package]] |
| 1684 | name = "getrandom" | 2110 | name = "getrandom" |
| 1685 | version = "0.2.17" | 2111 | version = "0.2.17" |
| @@ -1737,6 +2163,17 @@ version = "0.32.3" | |||
| 1737 | source = "registry+https://github.com/rust-lang/crates.io-index" | 2163 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 1738 | checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" | 2164 | checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" |
| 1739 | 2165 | ||
| 2166 | +[[package]] | ||
| 2167 | +name = "gl_generator" | ||
| 2168 | +version = "0.14.0" | ||
| 2169 | +source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2170 | +checksum = "1a95dfc23a2b4a9a2f5ab41d194f8bfda3cabec42af4e39f08c339eb2a0c124d" | ||
| 2171 | +dependencies = [ | ||
| 2172 | + "khronos_api", | ||
| 2173 | + "log", | ||
| 2174 | + "xml-rs", | ||
| 2175 | +] | ||
| 2176 | + | ||
| 1740 | [[package]] | 2177 | [[package]] |
| 1741 | name = "glob" | 2178 | name = "glob" |
| 1742 | version = "0.3.4" | 2179 | version = "0.3.4" |
| @@ -1755,6 +2192,84 @@ dependencies = [ | |||
| 1755 | "wasm-bindgen", | 2192 | "wasm-bindgen", |
| 1756 | ] | 2193 | ] |
| 1757 | 2194 | ||
| 2195 | +[[package]] | ||
| 2196 | +name = "glow" | ||
| 2197 | +version = "0.16.0" | ||
| 2198 | +source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2199 | +checksum = "c5e5ea60d70410161c8bf5da3fdfeaa1c72ed2c15f8bbb9d19fe3a4fad085f08" | ||
| 2200 | +dependencies = [ | ||
| 2201 | + "js-sys", | ||
| 2202 | + "slotmap", | ||
| 2203 | + "wasm-bindgen", | ||
| 2204 | + "web-sys", | ||
| 2205 | +] | ||
| 2206 | + | ||
| 2207 | +[[package]] | ||
| 2208 | +name = "glutin" | ||
| 2209 | +version = "0.32.3" | ||
| 2210 | +source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2211 | +checksum = "12124de845cacfebedff80e877bb37b5b75c34c5a4c89e47e1cdd67fb6041325" | ||
| 2212 | +dependencies = [ | ||
| 2213 | + "bitflags 2.13.1", | ||
| 2214 | + "cfg_aliases 0.2.2", | ||
| 2215 | + "cgl", | ||
| 2216 | + "dispatch2", | ||
| 2217 | + "glutin_egl_sys", | ||
| 2218 | + "glutin_glx_sys", | ||
| 2219 | + "glutin_wgl_sys", | ||
| 2220 | + "libloading", | ||
| 2221 | + "objc2 0.6.4", | ||
| 2222 | + "objc2-app-kit 0.3.2", | ||
| 2223 | + "objc2-core-foundation", | ||
| 2224 | + "objc2-foundation 0.3.2", | ||
| 2225 | + "once_cell", | ||
| 2226 | + "raw-window-handle", | ||
| 2227 | + "wayland-sys", | ||
| 2228 | + "windows-sys 0.52.0", | ||
| 2229 | + "x11-dl", | ||
| 2230 | +] | ||
| 2231 | + | ||
| 2232 | +[[package]] | ||
| 2233 | +name = "glutin-winit" | ||
| 2234 | +version = "0.5.0" | ||
| 2235 | +source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2236 | +checksum = "85edca7075f8fc728f28cb8fbb111a96c3b89e930574369e3e9c27eb75d3788f" | ||
| 2237 | +dependencies = [ | ||
| 2238 | + "cfg_aliases 0.2.2", | ||
| 2239 | + "glutin", | ||
| 2240 | + "raw-window-handle", | ||
| 2241 | + "winit", | ||
| 2242 | +] | ||
| 2243 | + | ||
| 2244 | +[[package]] | ||
| 2245 | +name = "glutin_egl_sys" | ||
| 2246 | +version = "0.7.1" | ||
| 2247 | +source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2248 | +checksum = "4c4680ba6195f424febdc3ba46e7a42a0e58743f2edb115297b86d7f8ecc02d2" | ||
| 2249 | +dependencies = [ | ||
| 2250 | + "gl_generator", | ||
| 2251 | + "windows-sys 0.52.0", | ||
| 2252 | +] | ||
| 2253 | + | ||
| 2254 | +[[package]] | ||
| 2255 | +name = "glutin_glx_sys" | ||
| 2256 | +version = "0.6.1" | ||
| 2257 | +source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2258 | +checksum = "8a7bb2938045a88b612499fbcba375a77198e01306f52272e692f8c1f3751185" | ||
| 2259 | +dependencies = [ | ||
| 2260 | + "gl_generator", | ||
| 2261 | + "x11-dl", | ||
| 2262 | +] | ||
| 2263 | + | ||
| 2264 | +[[package]] | ||
| 2265 | +name = "glutin_wgl_sys" | ||
| 2266 | +version = "0.6.1" | ||
| 2267 | +source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2268 | +checksum = "2c4ee00b289aba7a9e5306d57c2d05499b2e5dc427f84ac708bd2c090212cf3e" | ||
| 2269 | +dependencies = [ | ||
| 2270 | + "gl_generator", | ||
| 2271 | +] | ||
| 2272 | + | ||
| 1758 | [[package]] | 2273 | [[package]] |
| 1759 | name = "h2" | 2274 | name = "h2" |
| 1760 | version = "0.4.15" | 2275 | version = "0.4.15" |
| @@ -2224,6 +2739,8 @@ dependencies = [ | |||
| 2224 | "byteorder-lite", | 2739 | "byteorder-lite", |
| 2225 | "moxcms", | 2740 | "moxcms", |
| 2226 | "num-traits", | 2741 | "num-traits", |
| 2742 | + "png 0.18.1", | ||
| 2743 | + "tiff", | ||
| 2227 | "zune-core", | 2744 | "zune-core", |
| 2228 | "zune-jpeg", | 2745 | "zune-jpeg", |
| 2229 | ] | 2746 | ] |
| @@ -2750,6 +3267,12 @@ dependencies = [ | |||
| 2750 | "wasm-bindgen", | 3267 | "wasm-bindgen", |
| 2751 | ] | 3268 | ] |
| 2752 | 3269 | ||
| 3270 | +[[package]] | ||
| 3271 | +name = "khronos_api" | ||
| 3272 | +version = "3.1.0" | ||
| 3273 | +source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 3274 | +checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" | ||
| 3275 | + | ||
| 2753 | [[package]] | 3276 | [[package]] |
| 2754 | name = "lazy_static" | 3277 | name = "lazy_static" |
| 2755 | version = "1.5.0" | 3278 | version = "1.5.0" |
| @@ -2778,6 +3301,18 @@ version = "0.2.16" | |||
| 2778 | source = "registry+https://github.com/rust-lang/crates.io-index" | 3301 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 2779 | checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" | 3302 | checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" |
| 2780 | 3303 | ||
| 3304 | +[[package]] | ||
| 3305 | +name = "libredox" | ||
| 3306 | +version = "0.1.21" | ||
| 3307 | +source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 3308 | +checksum = "d7955dfc218a8afb29dfeffd540e3a6e96baeb94fe7138228dd7cc6937fbbf96" | ||
| 3309 | +dependencies = [ | ||
| 3310 | + "bitflags 2.13.1", | ||
| 3311 | + "libc", | ||
| 3312 | + "plain", | ||
| 3313 | + "redox_syscall 0.9.3", | ||
| 3314 | +] | ||
| 3315 | + | ||
| 2781 | [[package]] | 3316 | [[package]] |
| 2782 | name = "libspa" | 3317 | name = "libspa" |
| 2783 | version = "0.9.2" | 3318 | version = "0.9.2" |
| @@ -2799,12 +3334,24 @@ dependencies = [ | |||
| 2799 | name = "libspa-sys" | 3334 | name = "libspa-sys" |
| 2800 | version = "0.9.2" | 3335 | version = "0.9.2" |
| 2801 | source = "registry+https://github.com/rust-lang/crates.io-index" | 3336 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 2802 | -checksum = "901049455d2eb6decf9058235d745237952f4804bc584c5fcb41412e6adcc6e0" | 3337 | +checksum = "901049455d2eb6decf9058235d745237952f4804bc584c5fcb41412e6adcc6e0" |
| 2803 | -dependencies = [ | 3338 | +dependencies = [ |
| 2804 | - "bindgen 0.72.1", | 3339 | + "bindgen 0.72.1", |
| 2805 | - "cc", | 3340 | + "cc", |
| 2806 | - "system-deps", | 3341 | + "system-deps", |
| 2807 | -] | 3342 | +] |
| 3343 | + | ||
| 3344 | +[[package]] | ||
| 3345 | +name = "linux-raw-sys" | ||
| 3346 | +version = "0.4.15" | ||
| 3347 | +source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 3348 | +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" | ||
| 3349 | + | ||
| 3350 | +[[package]] | ||
| 3351 | +name = "linux-raw-sys" | ||
| 3352 | +version = "0.12.1" | ||
| 3353 | +source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 3354 | +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" | ||
| 2808 | 3355 | ||
| 2809 | [[package]] | 3356 | [[package]] |
| 2810 | name = "litemap" | 3357 | name = "litemap" |
| @@ -2891,6 +3438,24 @@ version = "2.8.3" | |||
| 2891 | source = "registry+https://github.com/rust-lang/crates.io-index" | 3438 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 2892 | checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" | 3439 | checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" |
| 2893 | 3440 | ||
| 3441 | +[[package]] | ||
| 3442 | +name = "memmap2" | ||
| 3443 | +version = "0.9.11" | ||
| 3444 | +source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 3445 | +checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0" | ||
| 3446 | +dependencies = [ | ||
| 3447 | + "libc", | ||
| 3448 | +] | ||
| 3449 | + | ||
| 3450 | +[[package]] | ||
| 3451 | +name = "memoffset" | ||
| 3452 | +version = "0.9.1" | ||
| 3453 | +source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 3454 | +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" | ||
| 3455 | +dependencies = [ | ||
| 3456 | + "autocfg", | ||
| 3457 | +] | ||
| 3458 | + | ||
| 2894 | [[package]] | 3459 | [[package]] |
| 2895 | name = "minimal-lexical" | 3460 | name = "minimal-lexical" |
| 2896 | version = "0.2.1" | 3461 | version = "0.2.1" |
| @@ -2904,6 +3469,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| 2904 | checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" | 3469 | checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" |
| 2905 | dependencies = [ | 3470 | dependencies = [ |
| 2906 | "adler2", | 3471 | "adler2", |
| 3472 | + "simd-adler32", | ||
| 3473 | +] | ||
| 3474 | + | ||
| 3475 | +[[package]] | ||
| 3476 | +name = "miniz_oxide" | ||
| 3477 | +version = "0.9.1" | ||
| 3478 | +source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 3479 | +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" | ||
| 3480 | +dependencies = [ | ||
| 3481 | + "adler2", | ||
| 3482 | + "simd-adler32", | ||
| 2907 | ] | 3483 | ] |
| 2908 | 3484 | ||
| 2909 | [[package]] | 3485 | [[package]] |
| @@ -3024,6 +3600,20 @@ dependencies = [ | |||
| 3024 | "pxfm", | 3600 | "pxfm", |
| 3025 | ] | 3601 | ] |
| 3026 | 3602 | ||
| 3603 | +[[package]] | ||
| 3604 | +name = "mp4" | ||
| 3605 | +version = "0.14.0" | ||
| 3606 | +source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 3607 | +checksum = "c9ef834d5ed55e494a2ae350220314dc4aacd1c43a9498b00e320e0ea352a5c3" | ||
| 3608 | +dependencies = [ | ||
| 3609 | + "byteorder", | ||
| 3610 | + "bytes", | ||
| 3611 | + "num-rational", | ||
| 3612 | + "serde", | ||
| 3613 | + "serde_json", | ||
| 3614 | + "thiserror 1.0.69", | ||
| 3615 | +] | ||
| 3616 | + | ||
| 3027 | [[package]] | 3617 | [[package]] |
| 3028 | name = "n0-error" | 3618 | name = "n0-error" |
| 3029 | version = "0.1.3" | 3619 | version = "0.1.3" |
| @@ -3098,6 +3688,7 @@ dependencies = [ | |||
| 3098 | "log", | 3688 | "log", |
| 3099 | "ndk-sys", | 3689 | "ndk-sys", |
| 3100 | "num_enum", | 3690 | "num_enum", |
| 3691 | + "raw-window-handle", | ||
| 3101 | "thiserror 1.0.69", | 3692 | "thiserror 1.0.69", |
| 3102 | ] | 3693 | ] |
| 3103 | 3694 | ||
| @@ -3122,7 +3713,7 @@ version = "0.40.1" | |||
| 3122 | source = "registry+https://github.com/rust-lang/crates.io-index" | 3713 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 3123 | checksum = "1b0a0096d9613ee878dba89bbe595f079d373e3f1960d882e4f2f78ff9c30a0a" | 3714 | checksum = "1b0a0096d9613ee878dba89bbe595f079d373e3f1960d882e4f2f78ff9c30a0a" |
| 3124 | dependencies = [ | 3715 | dependencies = [ |
| 3125 | - "block2", | 3716 | + "block2 0.6.2", |
| 3126 | "dispatch2", | 3717 | "dispatch2", |
| 3127 | "dlopen2", | 3718 | "dlopen2", |
| 3128 | "ipnet", | 3719 | "ipnet", |
| @@ -3247,6 +3838,12 @@ dependencies = [ | |||
| 3247 | "libc", | 3838 | "libc", |
| 3248 | ] | 3839 | ] |
| 3249 | 3840 | ||
| 3841 | +[[package]] | ||
| 3842 | +name = "nohash-hasher" | ||
| 3843 | +version = "0.2.0" | ||
| 3844 | +source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 3845 | +checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" | ||
| 3846 | + | ||
| 3250 | [[package]] | 3847 | [[package]] |
| 3251 | name = "nom" | 3848 | name = "nom" |
| 3252 | version = "7.1.3" | 3849 | version = "7.1.3" |
| @@ -3404,6 +4001,18 @@ dependencies = [ | |||
| 3404 | "num-traits", | 4001 | "num-traits", |
| 3405 | ] | 4002 | ] |
| 3406 | 4003 | ||
| 4004 | +[[package]] | ||
| 4005 | +name = "num-rational" | ||
| 4006 | +version = "0.4.2" | ||
| 4007 | +source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 4008 | +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" | ||
| 4009 | +dependencies = [ | ||
| 4010 | + "num-bigint", | ||
| 4011 | + "num-integer", | ||
| 4012 | + "num-traits", | ||
| 4013 | + "serde", | ||
| 4014 | +] | ||
| 4015 | + | ||
| 3407 | [[package]] | 4016 | [[package]] |
| 3408 | name = "num-traits" | 4017 | name = "num-traits" |
| 3409 | version = "0.2.19" | 4018 | version = "0.2.19" |
| @@ -3454,6 +4063,22 @@ dependencies = [ | |||
| 3454 | "libc", | 4063 | "libc", |
| 3455 | ] | 4064 | ] |
| 3456 | 4065 | ||
| 4066 | +[[package]] | ||
| 4067 | +name = "objc-sys" | ||
| 4068 | +version = "0.3.5" | ||
| 4069 | +source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 4070 | +checksum = "cdb91bdd390c7ce1a8607f35f3ca7151b65afc0ff5ff3b34fa350f7d7c7e4310" | ||
| 4071 | + | ||
| 4072 | +[[package]] | ||
| 4073 | +name = "objc2" | ||
| 4074 | +version = "0.5.2" | ||
| 4075 | +source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 4076 | +checksum = "46a785d4eeff09c14c487497c162e92766fbb3e4059a71840cecc03d9a50b804" | ||
| 4077 | +dependencies = [ | ||
| 4078 | + "objc-sys", | ||
| 4079 | + "objc2-encode", | ||
| 4080 | +] | ||
| 4081 | + | ||
| 3457 | [[package]] | 4082 | [[package]] |
| 3458 | name = "objc2" | 4083 | name = "objc2" |
| 3459 | version = "0.6.4" | 4084 | version = "0.6.4" |
| @@ -3463,6 +4088,35 @@ dependencies = [ | |||
| 3463 | "objc2-encode", | 4088 | "objc2-encode", |
| 3464 | ] | 4089 | ] |
| 3465 | 4090 | ||
| 4091 | +[[package]] | ||
| 4092 | +name = "objc2-app-kit" | ||
| 4093 | +version = "0.2.2" | ||
| 4094 | +source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 4095 | +checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff" | ||
| 4096 | +dependencies = [ | ||
| 4097 | + "bitflags 2.13.1", | ||
| 4098 | + "block2 0.5.1", | ||
| 4099 | + "libc", | ||
| 4100 | + "objc2 0.5.2", | ||
| 4101 | + "objc2-core-data", | ||
| 4102 | + "objc2-core-image 0.2.2", | ||
| 4103 | + "objc2-foundation 0.2.2", | ||
| 4104 | + "objc2-quartz-core 0.2.2", | ||
| 4105 | +] | ||
| 4106 | + | ||
| 4107 | +[[package]] | ||
| 4108 | +name = "objc2-app-kit" | ||
| 4109 | +version = "0.3.2" | ||
| 4110 | +source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 4111 | +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" | ||
| 4112 | +dependencies = [ | ||
| 4113 | + "bitflags 2.13.1", | ||
| 4114 | + "objc2 0.6.4", | ||
| 4115 | + "objc2-core-foundation", | ||
| 4116 | + "objc2-core-graphics", | ||
| 4117 | + "objc2-foundation 0.3.2", | ||
| 4118 | +] | ||
| 4119 | + | ||
| 3466 | [[package]] | 4120 | [[package]] |
| 3467 | name = "objc2-audio-toolbox" | 4121 | name = "objc2-audio-toolbox" |
| 3468 | version = "0.3.2" | 4122 | version = "0.3.2" |
| @@ -3471,11 +4125,11 @@ checksum = "6948501a91121d6399b79abaa33a8aa4ea7857fe019f341b8c23ad6e81b79b08" | |||
| 3471 | dependencies = [ | 4125 | dependencies = [ |
| 3472 | "bitflags 2.13.1", | 4126 | "bitflags 2.13.1", |
| 3473 | "libc", | 4127 | "libc", |
| 3474 | - "objc2", | 4128 | + "objc2 0.6.4", |
| 3475 | "objc2-core-audio", | 4129 | "objc2-core-audio", |
| 3476 | "objc2-core-audio-types", | 4130 | "objc2-core-audio-types", |
| 3477 | "objc2-core-foundation", | 4131 | "objc2-core-foundation", |
| 3478 | - "objc2-foundation", | 4132 | + "objc2-foundation 0.3.2", |
| 3479 | ] | 4133 | ] |
| 3480 | 4134 | ||
| 3481 | [[package]] | 4135 | [[package]] |
| @@ -3485,19 +4139,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| 3485 | checksum = "478ae33fcac9df0a18db8302387c666b8ef08a3e2d62b510ca4fc278a384b6c0" | 4139 | checksum = "478ae33fcac9df0a18db8302387c666b8ef08a3e2d62b510ca4fc278a384b6c0" |
| 3486 | dependencies = [ | 4140 | dependencies = [ |
| 3487 | "bitflags 2.13.1", | 4141 | "bitflags 2.13.1", |
| 3488 | - "block2", | 4142 | + "block2 0.6.2", |
| 3489 | "dispatch2", | 4143 | "dispatch2", |
| 3490 | - "objc2", | 4144 | + "objc2 0.6.4", |
| 3491 | "objc2-avf-audio", | 4145 | "objc2-avf-audio", |
| 3492 | "objc2-core-audio-types", | 4146 | "objc2-core-audio-types", |
| 3493 | "objc2-core-foundation", | 4147 | "objc2-core-foundation", |
| 3494 | "objc2-core-graphics", | 4148 | "objc2-core-graphics", |
| 3495 | - "objc2-core-image", | 4149 | + "objc2-core-image 0.3.2", |
| 3496 | "objc2-core-video", | 4150 | "objc2-core-video", |
| 3497 | - "objc2-foundation", | 4151 | + "objc2-foundation 0.3.2", |
| 3498 | "objc2-image-io", | 4152 | "objc2-image-io", |
| 3499 | "objc2-media-toolbox", | 4153 | "objc2-media-toolbox", |
| 3500 | - "objc2-quartz-core", | 4154 | + "objc2-quartz-core 0.3.2", |
| 3501 | ] | 4155 | ] |
| 3502 | 4156 | ||
| 3503 | [[package]] | 4157 | [[package]] |
| @@ -3506,8 +4160,32 @@ version = "0.3.2" | |||
| 3506 | source = "registry+https://github.com/rust-lang/crates.io-index" | 4160 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 3507 | checksum = "13a380031deed8e99db00065c45937da434ca987c034e13b87e4441f9e4090be" | 4161 | checksum = "13a380031deed8e99db00065c45937da434ca987c034e13b87e4441f9e4090be" |
| 3508 | dependencies = [ | 4162 | dependencies = [ |
| 3509 | - "objc2", | 4163 | + "objc2 0.6.4", |
| 3510 | - "objc2-foundation", | 4164 | + "objc2-foundation 0.3.2", |
| 4165 | +] | ||
| 4166 | + | ||
| 4167 | +[[package]] | ||
| 4168 | +name = "objc2-cloud-kit" | ||
| 4169 | +version = "0.2.2" | ||
| 4170 | +source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 4171 | +checksum = "74dd3b56391c7a0596a295029734d3c1c5e7e510a4cb30245f8221ccea96b009" | ||
| 4172 | +dependencies = [ | ||
| 4173 | + "bitflags 2.13.1", | ||
| 4174 | + "block2 0.5.1", | ||
| 4175 | + "objc2 0.5.2", | ||
| 4176 | + "objc2-core-location", | ||
| 4177 | + "objc2-foundation 0.2.2", | ||
| 4178 | +] | ||
| 4179 | + | ||
| 4180 | +[[package]] | ||
| 4181 | +name = "objc2-contacts" | ||
| 4182 | +version = "0.2.2" | ||
| 4183 | +source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 4184 | +checksum = "a5ff520e9c33812fd374d8deecef01d4a840e7b41862d849513de77e44aa4889" | ||
| 4185 | +dependencies = [ | ||
| 4186 | + "block2 0.5.1", | ||
| 4187 | + "objc2 0.5.2", | ||
| 4188 | + "objc2-foundation 0.2.2", | ||
| 3511 | ] | 4189 | ] |
| 3512 | 4190 | ||
| 3513 | [[package]] | 4191 | [[package]] |
| @@ -3517,10 +4195,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| 3517 | checksum = "e1eebcea8b0dbff5f7c8504f3107c68fc061a3eb44932051c8cf8a68d969c3b2" | 4195 | checksum = "e1eebcea8b0dbff5f7c8504f3107c68fc061a3eb44932051c8cf8a68d969c3b2" |
| 3518 | dependencies = [ | 4196 | dependencies = [ |
| 3519 | "dispatch2", | 4197 | "dispatch2", |
| 3520 | - "objc2", | 4198 | + "objc2 0.6.4", |
| 3521 | "objc2-core-audio-types", | 4199 | "objc2-core-audio-types", |
| 3522 | "objc2-core-foundation", | 4200 | "objc2-core-foundation", |
| 3523 | - "objc2-foundation", | 4201 | + "objc2-foundation 0.3.2", |
| 3524 | ] | 4202 | ] |
| 3525 | 4203 | ||
| 3526 | [[package]] | 4204 | [[package]] |
| @@ -3530,7 +4208,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| 3530 | checksum = "5a89f2ec274a0cf4a32642b2991e8b351a404d290da87bb6a9a9d8632490bd1c" | 4208 | checksum = "5a89f2ec274a0cf4a32642b2991e8b351a404d290da87bb6a9a9d8632490bd1c" |
| 3531 | dependencies = [ | 4209 | dependencies = [ |
| 3532 | "bitflags 2.13.1", | 4210 | "bitflags 2.13.1", |
| 3533 | - "objc2", | 4211 | + "objc2 0.6.4", |
| 4212 | +] | ||
| 4213 | + | ||
| 4214 | +[[package]] | ||
| 4215 | +name = "objc2-core-data" | ||
| 4216 | +version = "0.2.2" | ||
| 4217 | +source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 4218 | +checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef" | ||
| 4219 | +dependencies = [ | ||
| 4220 | + "bitflags 2.13.1", | ||
| 4221 | + "block2 0.5.1", | ||
| 4222 | + "objc2 0.5.2", | ||
| 4223 | + "objc2-foundation 0.2.2", | ||
| 3534 | ] | 4224 | ] |
| 3535 | 4225 | ||
| 3536 | [[package]] | 4226 | [[package]] |
| @@ -3540,10 +4230,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| 3540 | checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" | 4230 | checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" |
| 3541 | dependencies = [ | 4231 | dependencies = [ |
| 3542 | "bitflags 2.13.1", | 4232 | "bitflags 2.13.1", |
| 3543 | - "block2", | 4233 | + "block2 0.6.2", |
| 3544 | "dispatch2", | 4234 | "dispatch2", |
| 3545 | "libc", | 4235 | "libc", |
| 3546 | - "objc2", | 4236 | + "objc2 0.6.4", |
| 3547 | ] | 4237 | ] |
| 3548 | 4238 | ||
| 3549 | [[package]] | 4239 | [[package]] |
| @@ -3554,19 +4244,43 @@ checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" | |||
| 3554 | dependencies = [ | 4244 | dependencies = [ |
| 3555 | "bitflags 2.13.1", | 4245 | "bitflags 2.13.1", |
| 3556 | "dispatch2", | 4246 | "dispatch2", |
| 3557 | - "objc2", | 4247 | + "objc2 0.6.4", |
| 3558 | "objc2-core-foundation", | 4248 | "objc2-core-foundation", |
| 3559 | "objc2-io-surface", | 4249 | "objc2-io-surface", |
| 3560 | ] | 4250 | ] |
| 3561 | 4251 | ||
| 4252 | +[[package]] | ||
| 4253 | +name = "objc2-core-image" | ||
| 4254 | +version = "0.2.2" | ||
| 4255 | +source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 4256 | +checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80" | ||
| 4257 | +dependencies = [ | ||
| 4258 | + "block2 0.5.1", | ||
| 4259 | + "objc2 0.5.2", | ||
| 4260 | + "objc2-foundation 0.2.2", | ||
| 4261 | + "objc2-metal 0.2.2", | ||
| 4262 | +] | ||
| 4263 | + | ||
| 3562 | [[package]] | 4264 | [[package]] |
| 3563 | name = "objc2-core-image" | 4265 | name = "objc2-core-image" |
| 3564 | version = "0.3.2" | 4266 | version = "0.3.2" |
| 3565 | source = "registry+https://github.com/rust-lang/crates.io-index" | 4267 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 3566 | checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006" | 4268 | checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006" |
| 3567 | dependencies = [ | 4269 | dependencies = [ |
| 3568 | - "objc2", | 4270 | + "objc2 0.6.4", |
| 3569 | - "objc2-foundation", | 4271 | + "objc2-foundation 0.3.2", |
| 4272 | +] | ||
| 4273 | + | ||
| 4274 | +[[package]] | ||
| 4275 | +name = "objc2-core-location" | ||
| 4276 | +version = "0.2.2" | ||
| 4277 | +source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 4278 | +checksum = "000cfee34e683244f284252ee206a27953279d370e309649dc3ee317b37e5781" | ||
| 4279 | +dependencies = [ | ||
| 4280 | + "block2 0.5.1", | ||
| 4281 | + "objc2 0.5.2", | ||
| 4282 | + "objc2-contacts", | ||
| 4283 | + "objc2-foundation 0.2.2", | ||
| 3570 | ] | 4284 | ] |
| 3571 | 4285 | ||
| 3572 | [[package]] | 4286 | [[package]] |
| @@ -3576,9 +4290,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| 3576 | checksum = "05ec576860167a15dd9fce7fbee7512beb4e31f532159d3482d1f9c6caedf31d" | 4290 | checksum = "05ec576860167a15dd9fce7fbee7512beb4e31f532159d3482d1f9c6caedf31d" |
| 3577 | dependencies = [ | 4291 | dependencies = [ |
| 3578 | "bitflags 2.13.1", | 4292 | "bitflags 2.13.1", |
| 3579 | - "block2", | 4293 | + "block2 0.6.2", |
| 3580 | "dispatch2", | 4294 | "dispatch2", |
| 3581 | - "objc2", | 4295 | + "objc2 0.6.4", |
| 3582 | "objc2-core-audio", | 4296 | "objc2-core-audio", |
| 3583 | "objc2-core-audio-types", | 4297 | "objc2-core-audio-types", |
| 3584 | "objc2-core-foundation", | 4298 | "objc2-core-foundation", |
| @@ -3592,12 +4306,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| 3592 | checksum = "d425caf1df73233f29fd8a5c3e5edbc30d2d4307870f802d18f00d83dc5141a6" | 4306 | checksum = "d425caf1df73233f29fd8a5c3e5edbc30d2d4307870f802d18f00d83dc5141a6" |
| 3593 | dependencies = [ | 4307 | dependencies = [ |
| 3594 | "bitflags 2.13.1", | 4308 | "bitflags 2.13.1", |
| 3595 | - "block2", | 4309 | + "block2 0.6.2", |
| 3596 | - "objc2", | 4310 | + "objc2 0.6.4", |
| 3597 | "objc2-core-foundation", | 4311 | "objc2-core-foundation", |
| 3598 | "objc2-core-graphics", | 4312 | "objc2-core-graphics", |
| 3599 | "objc2-io-surface", | 4313 | "objc2-io-surface", |
| 3600 | - "objc2-metal", | 4314 | + "objc2-metal 0.3.2", |
| 3601 | ] | 4315 | ] |
| 3602 | 4316 | ||
| 3603 | [[package]] | 4317 | [[package]] |
| @@ -3606,6 +4320,19 @@ version = "4.1.0" | |||
| 3606 | source = "registry+https://github.com/rust-lang/crates.io-index" | 4320 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 3607 | checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" | 4321 | checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" |
| 3608 | 4322 | ||
| 4323 | +[[package]] | ||
| 4324 | +name = "objc2-foundation" | ||
| 4325 | +version = "0.2.2" | ||
| 4326 | +source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 4327 | +checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" | ||
| 4328 | +dependencies = [ | ||
| 4329 | + "bitflags 2.13.1", | ||
| 4330 | + "block2 0.5.1", | ||
| 4331 | + "dispatch", | ||
| 4332 | + "libc", | ||
| 4333 | + "objc2 0.5.2", | ||
| 4334 | +] | ||
| 4335 | + | ||
| 3609 | [[package]] | 4336 | [[package]] |
| 3610 | name = "objc2-foundation" | 4337 | name = "objc2-foundation" |
| 3611 | version = "0.3.2" | 4338 | version = "0.3.2" |
| @@ -3613,9 +4340,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| 3613 | checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" | 4340 | checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" |
| 3614 | dependencies = [ | 4341 | dependencies = [ |
| 3615 | "bitflags 2.13.1", | 4342 | "bitflags 2.13.1", |
| 3616 | - "block2", | 4343 | + "block2 0.6.2", |
| 3617 | "libc", | 4344 | "libc", |
| 3618 | - "objc2", | 4345 | + "objc2 0.6.4", |
| 3619 | "objc2-core-foundation", | 4346 | "objc2-core-foundation", |
| 3620 | ] | 4347 | ] |
| 3621 | 4348 | ||
| @@ -3625,7 +4352,7 @@ version = "0.3.2" | |||
| 3625 | source = "registry+https://github.com/rust-lang/crates.io-index" | 4352 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 3626 | checksum = "32b0446e98cf4a784cc7a0177715ff317eeaa8463841c616cfc78aa4f953c4ea" | 4353 | checksum = "32b0446e98cf4a784cc7a0177715ff317eeaa8463841c616cfc78aa4f953c4ea" |
| 3627 | dependencies = [ | 4354 | dependencies = [ |
| 3628 | - "objc2", | 4355 | + "objc2 0.6.4", |
| 3629 | "objc2-core-foundation", | 4356 | "objc2-core-foundation", |
| 3630 | "objc2-core-graphics", | 4357 | "objc2-core-graphics", |
| 3631 | ] | 4358 | ] |
| @@ -3637,22 +4364,46 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| 3637 | checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" | 4364 | checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" |
| 3638 | dependencies = [ | 4365 | dependencies = [ |
| 3639 | "bitflags 2.13.1", | 4366 | "bitflags 2.13.1", |
| 3640 | - "objc2", | 4367 | + "objc2 0.6.4", |
| 3641 | "objc2-core-foundation", | 4368 | "objc2-core-foundation", |
| 3642 | ] | 4369 | ] |
| 3643 | 4370 | ||
| 4371 | +[[package]] | ||
| 4372 | +name = "objc2-link-presentation" | ||
| 4373 | +version = "0.2.2" | ||
| 4374 | +source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 4375 | +checksum = "a1a1ae721c5e35be65f01a03b6d2ac13a54cb4fa70d8a5da293d7b0020261398" | ||
| 4376 | +dependencies = [ | ||
| 4377 | + "block2 0.5.1", | ||
| 4378 | + "objc2 0.5.2", | ||
| 4379 | + "objc2-app-kit 0.2.2", | ||
| 4380 | + "objc2-foundation 0.2.2", | ||
| 4381 | +] | ||
| 4382 | + | ||
| 3644 | [[package]] | 4383 | [[package]] |
| 3645 | name = "objc2-media-toolbox" | 4384 | name = "objc2-media-toolbox" |
| 3646 | version = "0.3.2" | 4385 | version = "0.3.2" |
| 3647 | source = "registry+https://github.com/rust-lang/crates.io-index" | 4386 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 3648 | checksum = "edd9fdde720df3da7046bb9097811000c1e7ab5cd579fa89d96b27d56781fb30" | 4387 | checksum = "edd9fdde720df3da7046bb9097811000c1e7ab5cd579fa89d96b27d56781fb30" |
| 3649 | dependencies = [ | 4388 | dependencies = [ |
| 3650 | - "objc2", | 4389 | + "objc2 0.6.4", |
| 3651 | "objc2-core-audio-types", | 4390 | "objc2-core-audio-types", |
| 3652 | "objc2-core-foundation", | 4391 | "objc2-core-foundation", |
| 3653 | "objc2-core-media", | 4392 | "objc2-core-media", |
| 3654 | ] | 4393 | ] |
| 3655 | 4394 | ||
| 4395 | +[[package]] | ||
| 4396 | +name = "objc2-metal" | ||
| 4397 | +version = "0.2.2" | ||
| 4398 | +source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 4399 | +checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" | ||
| 4400 | +dependencies = [ | ||
| 4401 | + "bitflags 2.13.1", | ||
| 4402 | + "block2 0.5.1", | ||
| 4403 | + "objc2 0.5.2", | ||
| 4404 | + "objc2-foundation 0.2.2", | ||
| 4405 | +] | ||
| 4406 | + | ||
| 3656 | [[package]] | 4407 | [[package]] |
| 3657 | name = "objc2-metal" | 4408 | name = "objc2-metal" |
| 3658 | version = "0.3.2" | 4409 | version = "0.3.2" |
| @@ -3660,8 +4411,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| 3660 | checksum = "a0125f776a10d00af4152d74616409f0d4a2053a6f57fa5b7d6aa2854ac04794" | 4411 | checksum = "a0125f776a10d00af4152d74616409f0d4a2053a6f57fa5b7d6aa2854ac04794" |
| 3661 | dependencies = [ | 4412 | dependencies = [ |
| 3662 | "bitflags 2.13.1", | 4413 | "bitflags 2.13.1", |
| 3663 | - "objc2", | 4414 | + "objc2 0.6.4", |
| 3664 | - "objc2-foundation", | 4415 | + "objc2-foundation 0.3.2", |
| 4416 | +] | ||
| 4417 | + | ||
| 4418 | +[[package]] | ||
| 4419 | +name = "objc2-quartz-core" | ||
| 4420 | +version = "0.2.2" | ||
| 4421 | +source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 4422 | +checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" | ||
| 4423 | +dependencies = [ | ||
| 4424 | + "bitflags 2.13.1", | ||
| 4425 | + "block2 0.5.1", | ||
| 4426 | + "objc2 0.5.2", | ||
| 4427 | + "objc2-foundation 0.2.2", | ||
| 4428 | + "objc2-metal 0.2.2", | ||
| 3665 | ] | 4429 | ] |
| 3666 | 4430 | ||
| 3667 | [[package]] | 4431 | [[package]] |
| @@ -3671,8 +4435,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| 3671 | checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" | 4435 | checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" |
| 3672 | dependencies = [ | 4436 | dependencies = [ |
| 3673 | "bitflags 2.13.1", | 4437 | "bitflags 2.13.1", |
| 3674 | - "objc2", | 4438 | + "objc2 0.6.4", |
| 3675 | - "objc2-foundation", | 4439 | + "objc2-foundation 0.3.2", |
| 3676 | ] | 4440 | ] |
| 3677 | 4441 | ||
| 3678 | [[package]] | 4442 | [[package]] |
| @@ -3682,10 +4446,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| 3682 | checksum = "709fe137109bd1e8b5a99390f77a7d8b2961dafc1a1c5db8f2e60329ad6d895a" | 4446 | checksum = "709fe137109bd1e8b5a99390f77a7d8b2961dafc1a1c5db8f2e60329ad6d895a" |
| 3683 | dependencies = [ | 4447 | dependencies = [ |
| 3684 | "bitflags 2.13.1", | 4448 | "bitflags 2.13.1", |
| 3685 | - "objc2", | 4449 | + "objc2 0.6.4", |
| 3686 | "objc2-core-foundation", | 4450 | "objc2-core-foundation", |
| 3687 | ] | 4451 | ] |
| 3688 | 4452 | ||
| 4453 | +[[package]] | ||
| 4454 | +name = "objc2-symbols" | ||
| 4455 | +version = "0.2.2" | ||
| 4456 | +source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 4457 | +checksum = "0a684efe3dec1b305badae1a28f6555f6ddd3bb2c2267896782858d5a78404dc" | ||
| 4458 | +dependencies = [ | ||
| 4459 | + "objc2 0.5.2", | ||
| 4460 | + "objc2-foundation 0.2.2", | ||
| 4461 | +] | ||
| 4462 | + | ||
| 3689 | [[package]] | 4463 | [[package]] |
| 3690 | name = "objc2-system-configuration" | 4464 | name = "objc2-system-configuration" |
| 3691 | version = "0.3.2" | 4465 | version = "0.3.2" |
| @@ -3695,11 +4469,56 @@ dependencies = [ | |||
| 3695 | "bitflags 2.13.1", | 4469 | "bitflags 2.13.1", |
| 3696 | "dispatch2", | 4470 | "dispatch2", |
| 3697 | "libc", | 4471 | "libc", |
| 3698 | - "objc2", | 4472 | + "objc2 0.6.4", |
| 3699 | "objc2-core-foundation", | 4473 | "objc2-core-foundation", |
| 3700 | "objc2-security", | 4474 | "objc2-security", |
| 3701 | ] | 4475 | ] |
| 3702 | 4476 | ||
| 4477 | +[[package]] | ||
| 4478 | +name = "objc2-ui-kit" | ||
| 4479 | +version = "0.2.2" | ||
| 4480 | +source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 4481 | +checksum = "b8bb46798b20cd6b91cbd113524c490f1686f4c4e8f49502431415f3512e2b6f" | ||
| 4482 | +dependencies = [ | ||
| 4483 | + "bitflags 2.13.1", | ||
| 4484 | + "block2 0.5.1", | ||
| 4485 | + "objc2 0.5.2", | ||
| 4486 | + "objc2-cloud-kit", | ||
| 4487 | + "objc2-core-data", | ||
| 4488 | + "objc2-core-image 0.2.2", | ||
| 4489 | + "objc2-core-location", | ||
| 4490 | + "objc2-foundation 0.2.2", | ||
| 4491 | + "objc2-link-presentation", | ||
| 4492 | + "objc2-quartz-core 0.2.2", | ||
| 4493 | + "objc2-symbols", | ||
| 4494 | + "objc2-uniform-type-identifiers", | ||
| 4495 | + "objc2-user-notifications", | ||
| 4496 | +] | ||
| 4497 | + | ||
| 4498 | +[[package]] | ||
| 4499 | +name = "objc2-uniform-type-identifiers" | ||
| 4500 | +version = "0.2.2" | ||
| 4501 | +source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 4502 | +checksum = "44fa5f9748dbfe1ca6c0b79ad20725a11eca7c2218bceb4b005cb1be26273bfe" | ||
| 4503 | +dependencies = [ | ||
| 4504 | + "block2 0.5.1", | ||
| 4505 | + "objc2 0.5.2", | ||
| 4506 | + "objc2-foundation 0.2.2", | ||
| 4507 | +] | ||
| 4508 | + | ||
| 4509 | +[[package]] | ||
| 4510 | +name = "objc2-user-notifications" | ||
| 4511 | +version = "0.2.2" | ||
| 4512 | +source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 4513 | +checksum = "76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3" | ||
| 4514 | +dependencies = [ | ||
| 4515 | + "bitflags 2.13.1", | ||
| 4516 | + "block2 0.5.1", | ||
| 4517 | + "objc2 0.5.2", | ||
| 4518 | + "objc2-core-location", | ||
| 4519 | + "objc2-foundation 0.2.2", | ||
| 4520 | +] | ||
| 4521 | + | ||
| 3703 | [[package]] | 4522 | [[package]] |
| 3704 | name = "object" | 4523 | name = "object" |
| 3705 | version = "0.37.3" | 4524 | version = "0.37.3" |
| @@ -3767,6 +4586,25 @@ version = "0.2.1" | |||
| 3767 | source = "registry+https://github.com/rust-lang/crates.io-index" | 4586 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 3768 | checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" | 4587 | checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" |
| 3769 | 4588 | ||
| 4589 | +[[package]] | ||
| 4590 | +name = "orbclient" | ||
| 4591 | +version = "0.3.55" | ||
| 4592 | +source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 4593 | +checksum = "5df339f526ea9a60e371768d50efc2f2508c7203290731565d1f7a6f71d21747" | ||
| 4594 | +dependencies = [ | ||
| 4595 | + "libc", | ||
| 4596 | + "libredox", | ||
| 4597 | +] | ||
| 4598 | + | ||
| 4599 | +[[package]] | ||
| 4600 | +name = "owned_ttf_parser" | ||
| 4601 | +version = "0.25.1" | ||
| 4602 | +source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 4603 | +checksum = "36820e9051aca1014ddc75770aab4d68bc1e9e632f0f5627c4086bc216fb583b" | ||
| 4604 | +dependencies = [ | ||
| 4605 | + "ttf-parser", | ||
| 4606 | +] | ||
| 4607 | + | ||
| 3770 | [[package]] | 4608 | [[package]] |
| 3771 | name = "papaya" | 4609 | name = "papaya" |
| 3772 | version = "0.2.4" | 4610 | version = "0.2.4" |
| @@ -3803,7 +4641,7 @@ dependencies = [ | |||
| 3803 | "cfg-if", | 4641 | "cfg-if", |
| 3804 | "libc", | 4642 | "libc", |
| 3805 | "petgraph", | 4643 | "petgraph", |
| 3806 | - "redox_syscall", | 4644 | + "redox_syscall 0.5.18", |
| 3807 | "smallvec", | 4645 | "smallvec", |
| 3808 | "windows-link", | 4646 | "windows-link", |
| 3809 | ] | 4647 | ] |
| @@ -3957,6 +4795,12 @@ version = "0.3.33" | |||
| 3957 | source = "registry+https://github.com/rust-lang/crates.io-index" | 4795 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 3958 | checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" | 4796 | checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" |
| 3959 | 4797 | ||
| 4798 | +[[package]] | ||
| 4799 | +name = "plain" | ||
| 4800 | +version = "0.2.3" | ||
| 4801 | +source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 4802 | +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" | ||
| 4803 | + | ||
| 3960 | [[package]] | 4804 | [[package]] |
| 3961 | name = "plist" | 4805 | name = "plist" |
| 3962 | version = "1.10.0" | 4806 | version = "1.10.0" |
| @@ -3970,6 +4814,46 @@ dependencies = [ | |||
| 3970 | "time", | 4814 | "time", |
| 3971 | ] | 4815 | ] |
| 3972 | 4816 | ||
| 4817 | +[[package]] | ||
| 4818 | +name = "png" | ||
| 4819 | +version = "0.17.16" | ||
| 4820 | +source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 4821 | +checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" | ||
| 4822 | +dependencies = [ | ||
| 4823 | + "bitflags 1.3.2", | ||
| 4824 | + "crc32fast", | ||
| 4825 | + "fdeflate", | ||
| 4826 | + "flate2", | ||
| 4827 | + "miniz_oxide 0.8.9", | ||
| 4828 | +] | ||
| 4829 | + | ||
| 4830 | +[[package]] | ||
| 4831 | +name = "png" | ||
| 4832 | +version = "0.18.1" | ||
| 4833 | +source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 4834 | +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" | ||
| 4835 | +dependencies = [ | ||
| 4836 | + "bitflags 2.13.1", | ||
| 4837 | + "crc32fast", | ||
| 4838 | + "fdeflate", | ||
| 4839 | + "flate2", | ||
| 4840 | + "miniz_oxide 0.8.9", | ||
| 4841 | +] | ||
| 4842 | + | ||
| 4843 | +[[package]] | ||
| 4844 | +name = "polling" | ||
| 4845 | +version = "3.11.0" | ||
| 4846 | +source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 4847 | +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" | ||
| 4848 | +dependencies = [ | ||
| 4849 | + "cfg-if", | ||
| 4850 | + "concurrent-queue", | ||
| 4851 | + "hermit-abi", | ||
| 4852 | + "pin-project-lite", | ||
| 4853 | + "rustix 1.1.4", | ||
| 4854 | + "windows-sys 0.61.2", | ||
| 4855 | +] | ||
| 4856 | + | ||
| 3973 | [[package]] | 4857 | [[package]] |
| 3974 | name = "polyval" | 4858 | name = "polyval" |
| 3975 | version = "0.6.2" | 4859 | version = "0.6.2" |
| @@ -4116,6 +5000,12 @@ dependencies = [ | |||
| 4116 | "unicode-ident", | 5000 | "unicode-ident", |
| 4117 | ] | 5001 | ] |
| 4118 | 5002 | ||
| 5003 | +[[package]] | ||
| 5004 | +name = "profiling" | ||
| 5005 | +version = "1.0.18" | ||
| 5006 | +source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 5007 | +checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" | ||
| 5008 | + | ||
| 4119 | [[package]] | 5009 | [[package]] |
| 4120 | name = "pxfm" | 5010 | name = "pxfm" |
| 4121 | version = "0.1.30" | 5011 | version = "0.1.30" |
| @@ -4140,6 +5030,12 @@ dependencies = [ | |||
| 4140 | "web-transport-trait", | 5030 | "web-transport-trait", |
| 4141 | ] | 5031 | ] |
| 4142 | 5032 | ||
| 5033 | +[[package]] | ||
| 5034 | +name = "quick-error" | ||
| 5035 | +version = "2.0.1" | ||
| 5036 | +source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 5037 | +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" | ||
| 5038 | + | ||
| 4143 | [[package]] | 5039 | [[package]] |
| 4144 | name = "quick-xml" | 5040 | name = "quick-xml" |
| 4145 | version = "0.41.0" | 5041 | version = "0.41.0" |
| @@ -4314,6 +5210,12 @@ dependencies = [ | |||
| 4314 | "rand_core 0.10.1", | 5210 | "rand_core 0.10.1", |
| 4315 | ] | 5211 | ] |
| 4316 | 5212 | ||
| 5213 | +[[package]] | ||
| 5214 | +name = "raw-window-handle" | ||
| 5215 | +version = "0.6.2" | ||
| 5216 | +source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 5217 | +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" | ||
| 5218 | + | ||
| 4317 | [[package]] | 5219 | [[package]] |
| 4318 | name = "rayon" | 5220 | name = "rayon" |
| 4319 | version = "1.12.0" | 5221 | version = "1.12.0" |
| @@ -4356,6 +5258,15 @@ dependencies = [ | |||
| 4356 | "rustfft", | 5258 | "rustfft", |
| 4357 | ] | 5259 | ] |
| 4358 | 5260 | ||
| 5261 | +[[package]] | ||
| 5262 | +name = "redox_syscall" | ||
| 5263 | +version = "0.4.1" | ||
| 5264 | +source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 5265 | +checksum = "4722d768eff46b75989dd134e5c353f0d6296e5aaa3132e776cbdb56be7731aa" | ||
| 5266 | +dependencies = [ | ||
| 5267 | + "bitflags 1.3.2", | ||
| 5268 | +] | ||
| 5269 | + | ||
| 4359 | [[package]] | 5270 | [[package]] |
| 4360 | name = "redox_syscall" | 5271 | name = "redox_syscall" |
| 4361 | version = "0.5.18" | 5272 | version = "0.5.18" |
| @@ -4365,6 +5276,15 @@ dependencies = [ | |||
| 4365 | "bitflags 2.13.1", | 5276 | "bitflags 2.13.1", |
| 4366 | ] | 5277 | ] |
| 4367 | 5278 | ||
| 5279 | +[[package]] | ||
| 5280 | +name = "redox_syscall" | ||
| 5281 | +version = "0.9.3" | ||
| 5282 | +source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 5283 | +checksum = "d678d17679829e73d371e96880897e98fee2ded7acc0a50bdf8af2affa4b2fe5" | ||
| 5284 | +dependencies = [ | ||
| 5285 | + "bitflags 2.13.1", | ||
| 5286 | +] | ||
| 5287 | + | ||
| 4368 | [[package]] | 5288 | [[package]] |
| 4369 | name = "ref-cast" | 5289 | name = "ref-cast" |
| 4370 | version = "1.0.26" | 5290 | version = "1.0.26" |
| @@ -4603,6 +5523,32 @@ dependencies = [ | |||
| 4603 | "nom 7.1.3", | 5523 | "nom 7.1.3", |
| 4604 | ] | 5524 | ] |
| 4605 | 5525 | ||
| 5526 | +[[package]] | ||
| 5527 | +name = "rustix" | ||
| 5528 | +version = "0.38.44" | ||
| 5529 | +source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 5530 | +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" | ||
| 5531 | +dependencies = [ | ||
| 5532 | + "bitflags 2.13.1", | ||
| 5533 | + "errno", | ||
| 5534 | + "libc", | ||
| 5535 | + "linux-raw-sys 0.4.15", | ||
| 5536 | + "windows-sys 0.59.0", | ||
| 5537 | +] | ||
| 5538 | + | ||
| 5539 | +[[package]] | ||
| 5540 | +name = "rustix" | ||
| 5541 | +version = "1.1.4" | ||
| 5542 | +source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 5543 | +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" | ||
| 5544 | +dependencies = [ | ||
| 5545 | + "bitflags 2.13.1", | ||
| 5546 | + "errno", | ||
| 5547 | + "libc", | ||
| 5548 | + "linux-raw-sys 0.12.1", | ||
| 5549 | + "windows-sys 0.61.2", | ||
| 5550 | +] | ||
| 5551 | + | ||
| 4606 | [[package]] | 5552 | [[package]] |
| 4607 | name = "rustls" | 5553 | name = "rustls" |
| 4608 | version = "0.23.43" | 5554 | version = "0.23.43" |
| @@ -4655,7 +5601,7 @@ version = "0.6.2" | |||
| 4655 | source = "registry+https://github.com/rust-lang/crates.io-index" | 5601 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 4656 | checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784" | 5602 | checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784" |
| 4657 | dependencies = [ | 5603 | dependencies = [ |
| 4658 | - "core-foundation", | 5604 | + "core-foundation 0.10.1", |
| 4659 | "core-foundation-sys", | 5605 | "core-foundation-sys", |
| 4660 | "jni 0.21.1", | 5606 | "jni 0.21.1", |
| 4661 | "log", | 5607 | "log", |
| @@ -4676,7 +5622,7 @@ version = "0.7.0" | |||
| 4676 | source = "registry+https://github.com/rust-lang/crates.io-index" | 5622 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 4677 | checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" | 5623 | checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" |
| 4678 | dependencies = [ | 5624 | dependencies = [ |
| 4679 | - "core-foundation", | 5625 | + "core-foundation 0.10.1", |
| 4680 | "core-foundation-sys", | 5626 | "core-foundation-sys", |
| 4681 | "jni 0.22.4", | 5627 | "jni 0.22.4", |
| 4682 | "log", | 5628 | "log", |
| @@ -4721,18 +5667,18 @@ version = "0.1.0" | |||
| 4721 | source = "git+https://github.com/n0-computer/iroh-live?rev=edd9bcc530a615f3ab3a6919e96790a077905376#edd9bcc530a615f3ab3a6919e96790a077905376" | 5667 | source = "git+https://github.com/n0-computer/iroh-live?rev=edd9bcc530a615f3ab3a6919e96790a077905376#edd9bcc530a615f3ab3a6919e96790a077905376" |
| 4722 | dependencies = [ | 5668 | dependencies = [ |
| 4723 | "anyhow", | 5669 | "anyhow", |
| 4724 | - "block2", | 5670 | + "block2 0.6.2", |
| 4725 | "bytes", | 5671 | "bytes", |
| 4726 | "derive_more", | 5672 | "derive_more", |
| 4727 | "dispatch2", | 5673 | "dispatch2", |
| 4728 | "image", | 5674 | "image", |
| 4729 | "libc", | 5675 | "libc", |
| 4730 | "nix 0.30.1", | 5676 | "nix 0.30.1", |
| 4731 | - "objc2", | 5677 | + "objc2 0.6.4", |
| 4732 | "objc2-av-foundation", | 5678 | "objc2-av-foundation", |
| 4733 | "objc2-core-media", | 5679 | "objc2-core-media", |
| 4734 | "objc2-core-video", | 5680 | "objc2-core-video", |
| 4735 | - "objc2-foundation", | 5681 | + "objc2-foundation 0.3.2", |
| 4736 | "rusty-codecs", | 5682 | "rusty-codecs", |
| 4737 | "tracing", | 5683 | "tracing", |
| 4738 | "v4l2r", | 5684 | "v4l2r", |
| @@ -4829,7 +5775,20 @@ checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" | |||
| 4829 | name = "scopeguard" | 5775 | name = "scopeguard" |
| 4830 | version = "1.2.0" | 5776 | version = "1.2.0" |
| 4831 | source = "registry+https://github.com/rust-lang/crates.io-index" | 5777 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 4832 | -checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" | 5778 | +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" |
| 5779 | + | ||
| 5780 | +[[package]] | ||
| 5781 | +name = "sctk-adwaita" | ||
| 5782 | +version = "0.10.1" | ||
| 5783 | +source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 5784 | +checksum = "b6277f0217056f77f1d8f49f2950ac6c278c0d607c45f5ee99328d792ede24ec" | ||
| 5785 | +dependencies = [ | ||
| 5786 | + "ab_glyph", | ||
| 5787 | + "log", | ||
| 5788 | + "memmap2", | ||
| 5789 | + "smithay-client-toolkit 0.19.2", | ||
| 5790 | + "tiny-skia", | ||
| 5791 | +] | ||
| 4833 | 5792 | ||
| 4834 | [[package]] | 5793 | [[package]] |
| 4835 | name = "security-framework" | 5794 | name = "security-framework" |
| @@ -4838,7 +5797,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| 4838 | checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" | 5797 | checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" |
| 4839 | dependencies = [ | 5798 | dependencies = [ |
| 4840 | "bitflags 2.13.1", | 5799 | "bitflags 2.13.1", |
| 4841 | - "core-foundation", | 5800 | + "core-foundation 0.10.1", |
| 4842 | "core-foundation-sys", | 5801 | "core-foundation-sys", |
| 4843 | "libc", | 5802 | "libc", |
| 4844 | "security-framework-sys", | 5803 | "security-framework-sys", |
| @@ -5073,6 +6032,12 @@ version = "3.0.0-rc.10" | |||
| 5073 | source = "registry+https://github.com/rust-lang/crates.io-index" | 6032 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 5074 | checksum = "7f1880df446116126965eeec169136b2e0251dba37c6223bcc819569550edea3" | 6033 | checksum = "7f1880df446116126965eeec169136b2e0251dba37c6223bcc819569550edea3" |
| 5075 | 6034 | ||
| 6035 | +[[package]] | ||
| 6036 | +name = "simd-adler32" | ||
| 6037 | +version = "0.3.10" | ||
| 6038 | +source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 6039 | +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" | ||
| 6040 | + | ||
| 5076 | [[package]] | 6041 | [[package]] |
| 5077 | name = "simd_cesu8" | 6042 | name = "simd_cesu8" |
| 5078 | version = "1.2.0" | 6043 | version = "1.2.0" |
| @@ -5110,12 +6075,93 @@ version = "0.4.12" | |||
| 5110 | source = "registry+https://github.com/rust-lang/crates.io-index" | 6075 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 5111 | checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" | 6076 | checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" |
| 5112 | 6077 | ||
| 6078 | +[[package]] | ||
| 6079 | +name = "slotmap" | ||
| 6080 | +version = "1.1.1" | ||
| 6081 | +source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 6082 | +checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" | ||
| 6083 | +dependencies = [ | ||
| 6084 | + "version_check", | ||
| 6085 | +] | ||
| 6086 | + | ||
| 5113 | [[package]] | 6087 | [[package]] |
| 5114 | name = "smallvec" | 6088 | name = "smallvec" |
| 5115 | version = "1.15.2" | 6089 | version = "1.15.2" |
| 5116 | source = "registry+https://github.com/rust-lang/crates.io-index" | 6090 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 5117 | checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" | 6091 | checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" |
| 5118 | 6092 | ||
| 6093 | +[[package]] | ||
| 6094 | +name = "smithay-client-toolkit" | ||
| 6095 | +version = "0.19.2" | ||
| 6096 | +source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 6097 | +checksum = "3457dea1f0eb631b4034d61d4d8c32074caa6cd1ab2d59f2327bd8461e2c0016" | ||
| 6098 | +dependencies = [ | ||
| 6099 | + "bitflags 2.13.1", | ||
| 6100 | + "calloop 0.13.0", | ||
| 6101 | + "calloop-wayland-source 0.3.0", | ||
| 6102 | + "cursor-icon", | ||
| 6103 | + "libc", | ||
| 6104 | + "log", | ||
| 6105 | + "memmap2", | ||
| 6106 | + "rustix 0.38.44", | ||
| 6107 | + "thiserror 1.0.69", | ||
| 6108 | + "wayland-backend", | ||
| 6109 | + "wayland-client", | ||
| 6110 | + "wayland-csd-frame", | ||
| 6111 | + "wayland-cursor", | ||
| 6112 | + "wayland-protocols", | ||
| 6113 | + "wayland-protocols-wlr", | ||
| 6114 | + "wayland-scanner", | ||
| 6115 | + "xkeysym", | ||
| 6116 | +] | ||
| 6117 | + | ||
| 6118 | +[[package]] | ||
| 6119 | +name = "smithay-client-toolkit" | ||
| 6120 | +version = "0.20.0" | ||
| 6121 | +source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 6122 | +checksum = "0512da38f5e2b31201a93524adb8d3136276fa4fe4aafab4e1f727a82b534cc0" | ||
| 6123 | +dependencies = [ | ||
| 6124 | + "bitflags 2.13.1", | ||
| 6125 | + "calloop 0.14.4", | ||
| 6126 | + "calloop-wayland-source 0.4.1", | ||
| 6127 | + "cursor-icon", | ||
| 6128 | + "libc", | ||
| 6129 | + "log", | ||
| 6130 | + "memmap2", | ||
| 6131 | + "rustix 1.1.4", | ||
| 6132 | + "thiserror 2.0.19", | ||
| 6133 | + "wayland-backend", | ||
| 6134 | + "wayland-client", | ||
| 6135 | + "wayland-csd-frame", | ||
| 6136 | + "wayland-cursor", | ||
| 6137 | + "wayland-protocols", | ||
| 6138 | + "wayland-protocols-experimental", | ||
| 6139 | + "wayland-protocols-misc", | ||
| 6140 | + "wayland-protocols-wlr", | ||
| 6141 | + "wayland-scanner", | ||
| 6142 | + "xkeysym", | ||
| 6143 | +] | ||
| 6144 | + | ||
| 6145 | +[[package]] | ||
| 6146 | +name = "smithay-clipboard" | ||
| 6147 | +version = "0.7.3" | ||
| 6148 | +source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 6149 | +checksum = "71704c03f739f7745053bde45fa203a46c58d25bc5c4efba1d9a60e9dba81226" | ||
| 6150 | +dependencies = [ | ||
| 6151 | + "libc", | ||
| 6152 | + "smithay-client-toolkit 0.20.0", | ||
| 6153 | + "wayland-backend", | ||
| 6154 | +] | ||
| 6155 | + | ||
| 6156 | +[[package]] | ||
| 6157 | +name = "smol_str" | ||
| 6158 | +version = "0.2.2" | ||
| 6159 | +source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 6160 | +checksum = "dd538fb6910ac1099850255cf94a94df6551fbdd602454387d0adb2d1ca6dead" | ||
| 6161 | +dependencies = [ | ||
| 6162 | + "serde", | ||
| 6163 | +] | ||
| 6164 | + | ||
| 5119 | [[package]] | 6165 | [[package]] |
| 5120 | name = "socket2" | 6166 | name = "socket2" |
| 5121 | version = "0.6.5" | 6167 | version = "0.6.5" |
| @@ -5253,6 +6299,12 @@ version = "0.2.4" | |||
| 5253 | source = "registry+https://github.com/rust-lang/crates.io-index" | 6299 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 5254 | checksum = "fe895eb47f22e2ddd4dabc02bce419d2e643c8e3b585c78158b349195bc24d82" | 6300 | checksum = "fe895eb47f22e2ddd4dabc02bce419d2e643c8e3b585c78158b349195bc24d82" |
| 5255 | 6301 | ||
| 6302 | +[[package]] | ||
| 6303 | +name = "strict-num" | ||
| 6304 | +version = "0.1.1" | ||
| 6305 | +source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 6306 | +checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" | ||
| 6307 | + | ||
| 5256 | [[package]] | 6308 | [[package]] |
| 5257 | name = "strsim" | 6309 | name = "strsim" |
| 5258 | version = "0.11.1" | 6310 | version = "0.11.1" |
| @@ -5486,6 +6538,20 @@ dependencies = [ | |||
| 5486 | "tracing", | 6538 | "tracing", |
| 5487 | ] | 6539 | ] |
| 5488 | 6540 | ||
| 6541 | +[[package]] | ||
| 6542 | +name = "tiff" | ||
| 6543 | +version = "0.11.3" | ||
| 6544 | +source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 6545 | +checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52" | ||
| 6546 | +dependencies = [ | ||
| 6547 | + "fax", | ||
| 6548 | + "flate2", | ||
| 6549 | + "half", | ||
| 6550 | + "quick-error", | ||
| 6551 | + "weezl", | ||
| 6552 | + "zune-jpeg", | ||
| 6553 | +] | ||
| 6554 | + | ||
| 5489 | [[package]] | 6555 | [[package]] |
| 5490 | name = "time" | 6556 | name = "time" |
| 5491 | version = "0.3.54" | 6557 | version = "0.3.54" |
| @@ -5519,6 +6585,31 @@ dependencies = [ | |||
| 5519 | "time-core", | 6585 | "time-core", |
| 5520 | ] | 6586 | ] |
| 5521 | 6587 | ||
| 6588 | +[[package]] | ||
| 6589 | +name = "tiny-skia" | ||
| 6590 | +version = "0.11.4" | ||
| 6591 | +source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 6592 | +checksum = "83d13394d44dae3207b52a326c0c85a8bf87f1541f23b0d143811088497b09ab" | ||
| 6593 | +dependencies = [ | ||
| 6594 | + "arrayref", | ||
| 6595 | + "arrayvec", | ||
| 6596 | + "bytemuck", | ||
| 6597 | + "cfg-if", | ||
| 6598 | + "log", | ||
| 6599 | + "tiny-skia-path", | ||
| 6600 | +] | ||
| 6601 | + | ||
| 6602 | +[[package]] | ||
| 6603 | +name = "tiny-skia-path" | ||
| 6604 | +version = "0.11.4" | ||
| 6605 | +source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 6606 | +checksum = "9c9e7fc0c2e86a30b117d0462aa261b72b7a99b7ebd7deb3a14ceda95c5bdc93" | ||
| 6607 | +dependencies = [ | ||
| 6608 | + "arrayref", | ||
| 6609 | + "bytemuck", | ||
| 6610 | + "strict-num", | ||
| 6611 | +] | ||
| 6612 | + | ||
| 5522 | [[package]] | 6613 | [[package]] |
| 5523 | name = "tinystr" | 6614 | name = "tinystr" |
| 5524 | version = "0.8.3" | 6615 | version = "0.8.3" |
| @@ -5828,6 +6919,12 @@ version = "0.2.5" | |||
| 5828 | source = "registry+https://github.com/rust-lang/crates.io-index" | 6919 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 5829 | checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" | 6920 | checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" |
| 5830 | 6921 | ||
| 6922 | +[[package]] | ||
| 6923 | +name = "ttf-parser" | ||
| 6924 | +version = "0.25.1" | ||
| 6925 | +source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 6926 | +checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" | ||
| 6927 | + | ||
| 5831 | [[package]] | 6928 | [[package]] |
| 5832 | name = "tungstenite" | 6929 | name = "tungstenite" |
| 5833 | version = "0.28.0" | 6930 | version = "0.28.0" |
| @@ -6040,6 +7137,35 @@ version = "0.9.5" | |||
| 6040 | source = "registry+https://github.com/rust-lang/crates.io-index" | 7137 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 6041 | checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" | 7138 | checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" |
| 6042 | 7139 | ||
| 7140 | +[[package]] | ||
| 7141 | +name = "vidya" | ||
| 7142 | +version = "0.1.0" | ||
| 7143 | +dependencies = [ | ||
| 7144 | + "egui", | ||
| 7145 | + "mp4", | ||
| 7146 | + "openh264", | ||
| 7147 | + "png 0.17.16", | ||
| 7148 | + "winit", | ||
| 7149 | + "zip", | ||
| 7150 | +] | ||
| 7151 | + | ||
| 7152 | +[[package]] | ||
| 7153 | +name = "vidya-ffi" | ||
| 7154 | +version = "0.1.0" | ||
| 7155 | +dependencies = [ | ||
| 7156 | + "arboard", | ||
| 7157 | + "egui", | ||
| 7158 | + "egui-winit", | ||
| 7159 | + "egui_glow", | ||
| 7160 | + "glow", | ||
| 7161 | + "glutin", | ||
| 7162 | + "glutin-winit", | ||
| 7163 | + "jni 0.22.4", | ||
| 7164 | + "png 0.17.16", | ||
| 7165 | + "vidya", | ||
| 7166 | + "winit", | ||
| 7167 | +] | ||
| 7168 | + | ||
| 6043 | [[package]] | 7169 | [[package]] |
| 6044 | name = "visibility" | 7170 | name = "visibility" |
| 6045 | version = "0.1.1" | 7171 | version = "0.1.1" |
| @@ -6166,6 +7292,141 @@ dependencies = [ | |||
| 6166 | "wasm-bindgen", | 7292 | "wasm-bindgen", |
| 6167 | ] | 7293 | ] |
| 6168 | 7294 | ||
| 7295 | +[[package]] | ||
| 7296 | +name = "wayland-backend" | ||
| 7297 | +version = "0.3.17" | ||
| 7298 | +source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 7299 | +checksum = "38a91b4eaddff87b1cd1074985e3713da4af2c49742d1b356b2c01670a67a078" | ||
| 7300 | +dependencies = [ | ||
| 7301 | + "cc", | ||
| 7302 | + "downcast-rs", | ||
| 7303 | + "rustix 1.1.4", | ||
| 7304 | + "scoped-tls", | ||
| 7305 | + "smallvec", | ||
| 7306 | + "wayland-sys", | ||
| 7307 | +] | ||
| 7308 | + | ||
| 7309 | +[[package]] | ||
| 7310 | +name = "wayland-client" | ||
| 7311 | +version = "0.31.15" | ||
| 7312 | +source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 7313 | +checksum = "e3c36a0f861ad76d0901f2800b46321410d9f73f2ea88aac0650d86c32688073" | ||
| 7314 | +dependencies = [ | ||
| 7315 | + "bitflags 2.13.1", | ||
| 7316 | + "rustix 1.1.4", | ||
| 7317 | + "wayland-backend", | ||
| 7318 | + "wayland-scanner", | ||
| 7319 | +] | ||
| 7320 | + | ||
| 7321 | +[[package]] | ||
| 7322 | +name = "wayland-csd-frame" | ||
| 7323 | +version = "0.3.0" | ||
| 7324 | +source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 7325 | +checksum = "625c5029dbd43d25e6aa9615e88b829a5cad13b2819c4ae129fdbb7c31ab4c7e" | ||
| 7326 | +dependencies = [ | ||
| 7327 | + "bitflags 2.13.1", | ||
| 7328 | + "cursor-icon", | ||
| 7329 | + "wayland-backend", | ||
| 7330 | +] | ||
| 7331 | + | ||
| 7332 | +[[package]] | ||
| 7333 | +name = "wayland-cursor" | ||
| 7334 | +version = "0.31.14" | ||
| 7335 | +source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 7336 | +checksum = "4a52d18780be9b1314328a3de5f930b73d2200112e3849ca6cb11822793fb34d" | ||
| 7337 | +dependencies = [ | ||
| 7338 | + "rustix 1.1.4", | ||
| 7339 | + "wayland-client", | ||
| 7340 | + "xcursor", | ||
| 7341 | +] | ||
| 7342 | + | ||
| 7343 | +[[package]] | ||
| 7344 | +name = "wayland-protocols" | ||
| 7345 | +version = "0.32.13" | ||
| 7346 | +source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 7347 | +checksum = "23d0c813de3daa2ed6520af85a3bd49b0e722a3078506899aa9686fea58dc4b6" | ||
| 7348 | +dependencies = [ | ||
| 7349 | + "bitflags 2.13.1", | ||
| 7350 | + "wayland-backend", | ||
| 7351 | + "wayland-client", | ||
| 7352 | + "wayland-scanner", | ||
| 7353 | +] | ||
| 7354 | + | ||
| 7355 | +[[package]] | ||
| 7356 | +name = "wayland-protocols-experimental" | ||
| 7357 | +version = "20250721.0.1" | ||
| 7358 | +source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 7359 | +checksum = "40a1f863128dcaaec790d7b4b396cc9b9a7a079e878e18c47e6c2d2c5a8dcbb1" | ||
| 7360 | +dependencies = [ | ||
| 7361 | + "bitflags 2.13.1", | ||
| 7362 | + "wayland-backend", | ||
| 7363 | + "wayland-client", | ||
| 7364 | + "wayland-protocols", | ||
| 7365 | + "wayland-scanner", | ||
| 7366 | +] | ||
| 7367 | + | ||
| 7368 | +[[package]] | ||
| 7369 | +name = "wayland-protocols-misc" | ||
| 7370 | +version = "0.3.12" | ||
| 7371 | +source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 7372 | +checksum = "6e9567599ef23e09b8dad6e429e5738d4509dfc46b3b21f32841a304d16b29c8" | ||
| 7373 | +dependencies = [ | ||
| 7374 | + "bitflags 2.13.1", | ||
| 7375 | + "wayland-backend", | ||
| 7376 | + "wayland-client", | ||
| 7377 | + "wayland-protocols", | ||
| 7378 | + "wayland-scanner", | ||
| 7379 | +] | ||
| 7380 | + | ||
| 7381 | +[[package]] | ||
| 7382 | +name = "wayland-protocols-plasma" | ||
| 7383 | +version = "0.3.12" | ||
| 7384 | +source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 7385 | +checksum = "2b6d8cf1eb2c1c31ed1f5643c88a6e53538129d4af80030c8cabd1f9fa884d91" | ||
| 7386 | +dependencies = [ | ||
| 7387 | + "bitflags 2.13.1", | ||
| 7388 | + "wayland-backend", | ||
| 7389 | + "wayland-client", | ||
| 7390 | + "wayland-protocols", | ||
| 7391 | + "wayland-scanner", | ||
| 7392 | +] | ||
| 7393 | + | ||
| 7394 | +[[package]] | ||
| 7395 | +name = "wayland-protocols-wlr" | ||
| 7396 | +version = "0.3.12" | ||
| 7397 | +source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 7398 | +checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234" | ||
| 7399 | +dependencies = [ | ||
| 7400 | + "bitflags 2.13.1", | ||
| 7401 | + "wayland-backend", | ||
| 7402 | + "wayland-client", | ||
| 7403 | + "wayland-protocols", | ||
| 7404 | + "wayland-scanner", | ||
| 7405 | +] | ||
| 7406 | + | ||
| 7407 | +[[package]] | ||
| 7408 | +name = "wayland-scanner" | ||
| 7409 | +version = "0.31.11" | ||
| 7410 | +source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 7411 | +checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0" | ||
| 7412 | +dependencies = [ | ||
| 7413 | + "proc-macro2", | ||
| 7414 | + "quick-xml", | ||
| 7415 | + "quote", | ||
| 7416 | +] | ||
| 7417 | + | ||
| 7418 | +[[package]] | ||
| 7419 | +name = "wayland-sys" | ||
| 7420 | +version = "0.31.11" | ||
| 7421 | +source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 7422 | +checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be" | ||
| 7423 | +dependencies = [ | ||
| 7424 | + "dlib", | ||
| 7425 | + "log", | ||
| 7426 | + "once_cell", | ||
| 7427 | + "pkg-config", | ||
| 7428 | +] | ||
| 7429 | + | ||
| 6169 | [[package]] | 7430 | [[package]] |
| 6170 | name = "web-async" | 7431 | name = "web-async" |
| 6171 | version = "0.1.5" | 7432 | version = "0.1.5" |
| @@ -6255,6 +7516,22 @@ dependencies = [ | |||
| 6255 | "bytes", | 7516 | "bytes", |
| 6256 | ] | 7517 | ] |
| 6257 | 7518 | ||
| 7519 | +[[package]] | ||
| 7520 | +name = "webbrowser" | ||
| 7521 | +version = "1.2.4" | ||
| 7522 | +source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 7523 | +checksum = "62c35be770821a214dbc362fc26908c853e776c0004294d0b10b8a6bad582f94" | ||
| 7524 | +dependencies = [ | ||
| 7525 | + "jni 0.22.4", | ||
| 7526 | + "log", | ||
| 7527 | + "ndk-context", | ||
| 7528 | + "objc2 0.6.4", | ||
| 7529 | + "objc2-app-kit 0.3.2", | ||
| 7530 | + "objc2-foundation 0.3.2", | ||
| 7531 | + "url", | ||
| 7532 | + "web-sys", | ||
| 7533 | +] | ||
| 7534 | + | ||
| 6258 | [[package]] | 7535 | [[package]] |
| 6259 | name = "webpki-root-certs" | 7536 | name = "webpki-root-certs" |
| 6260 | version = "1.0.9" | 7537 | version = "1.0.9" |
| @@ -6273,6 +7550,12 @@ dependencies = [ | |||
| 6273 | "rustls-pki-types", | 7550 | "rustls-pki-types", |
| 6274 | ] | 7551 | ] |
| 6275 | 7552 | ||
| 7553 | +[[package]] | ||
| 7554 | +name = "weezl" | ||
| 7555 | +version = "0.1.12" | ||
| 7556 | +source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 7557 | +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" | ||
| 7558 | + | ||
| 6276 | [[package]] | 7559 | [[package]] |
| 6277 | name = "wide" | 7560 | name = "wide" |
| 6278 | version = "1.5.0" | 7561 | version = "1.5.0" |
| @@ -6607,6 +7890,58 @@ version = "0.52.6" | |||
| 6607 | source = "registry+https://github.com/rust-lang/crates.io-index" | 7890 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 6608 | checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" | 7891 | checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" |
| 6609 | 7892 | ||
| 7893 | +[[package]] | ||
| 7894 | +name = "winit" | ||
| 7895 | +version = "0.30.13" | ||
| 7896 | +source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 7897 | +checksum = "a6755fa58a9f8350bd1e472d4c3fcc25f824ec358933bba33306d0b63df5978d" | ||
| 7898 | +dependencies = [ | ||
| 7899 | + "ahash", | ||
| 7900 | + "android-activity", | ||
| 7901 | + "atomic-waker", | ||
| 7902 | + "bitflags 2.13.1", | ||
| 7903 | + "block2 0.5.1", | ||
| 7904 | + "bytemuck", | ||
| 7905 | + "calloop 0.13.0", | ||
| 7906 | + "cfg_aliases 0.2.2", | ||
| 7907 | + "concurrent-queue", | ||
| 7908 | + "core-foundation 0.9.4", | ||
| 7909 | + "core-graphics", | ||
| 7910 | + "cursor-icon", | ||
| 7911 | + "dpi", | ||
| 7912 | + "js-sys", | ||
| 7913 | + "libc", | ||
| 7914 | + "memmap2", | ||
| 7915 | + "ndk", | ||
| 7916 | + "objc2 0.5.2", | ||
| 7917 | + "objc2-app-kit 0.2.2", | ||
| 7918 | + "objc2-foundation 0.2.2", | ||
| 7919 | + "objc2-ui-kit", | ||
| 7920 | + "orbclient", | ||
| 7921 | + "percent-encoding", | ||
| 7922 | + "pin-project", | ||
| 7923 | + "raw-window-handle", | ||
| 7924 | + "redox_syscall 0.4.1", | ||
| 7925 | + "rustix 0.38.44", | ||
| 7926 | + "sctk-adwaita", | ||
| 7927 | + "smithay-client-toolkit 0.19.2", | ||
| 7928 | + "smol_str", | ||
| 7929 | + "tracing", | ||
| 7930 | + "unicode-segmentation", | ||
| 7931 | + "wasm-bindgen", | ||
| 7932 | + "wasm-bindgen-futures", | ||
| 7933 | + "wayland-backend", | ||
| 7934 | + "wayland-client", | ||
| 7935 | + "wayland-protocols", | ||
| 7936 | + "wayland-protocols-plasma", | ||
| 7937 | + "web-sys", | ||
| 7938 | + "web-time", | ||
| 7939 | + "windows-sys 0.52.0", | ||
| 7940 | + "x11-dl", | ||
| 7941 | + "x11rb", | ||
| 7942 | + "xkbcommon-dl", | ||
| 7943 | +] | ||
| 7944 | + | ||
| 6610 | [[package]] | 7945 | [[package]] |
| 6611 | name = "winnow" | 7946 | name = "winnow" |
| 6612 | version = "1.0.4" | 7947 | version = "1.0.4" |
| @@ -6662,6 +7997,38 @@ dependencies = [ | |||
| 6662 | "web-sys", | 7997 | "web-sys", |
| 6663 | ] | 7998 | ] |
| 6664 | 7999 | ||
| 8000 | +[[package]] | ||
| 8001 | +name = "x11-dl" | ||
| 8002 | +version = "2.21.0" | ||
| 8003 | +source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 8004 | +checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" | ||
| 8005 | +dependencies = [ | ||
| 8006 | + "libc", | ||
| 8007 | + "once_cell", | ||
| 8008 | + "pkg-config", | ||
| 8009 | +] | ||
| 8010 | + | ||
| 8011 | +[[package]] | ||
| 8012 | +name = "x11rb" | ||
| 8013 | +version = "0.13.2" | ||
| 8014 | +source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 8015 | +checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" | ||
| 8016 | +dependencies = [ | ||
| 8017 | + "as-raw-xcb-connection", | ||
| 8018 | + "gethostname", | ||
| 8019 | + "libc", | ||
| 8020 | + "libloading", | ||
| 8021 | + "once_cell", | ||
| 8022 | + "rustix 1.1.4", | ||
| 8023 | + "x11rb-protocol", | ||
| 8024 | +] | ||
| 8025 | + | ||
| 8026 | +[[package]] | ||
| 8027 | +name = "x11rb-protocol" | ||
| 8028 | +version = "0.13.2" | ||
| 8029 | +source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 8030 | +checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" | ||
| 8031 | + | ||
| 6665 | [[package]] | 8032 | [[package]] |
| 6666 | name = "x509-parser" | 8033 | name = "x509-parser" |
| 6667 | version = "0.18.1" | 8034 | version = "0.18.1" |
| @@ -6680,6 +8047,31 @@ dependencies = [ | |||
| 6680 | "time", | 8047 | "time", |
| 6681 | ] | 8048 | ] |
| 6682 | 8049 | ||
| 8050 | +[[package]] | ||
| 8051 | +name = "xcursor" | ||
| 8052 | +version = "0.3.11" | ||
| 8053 | +source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 8054 | +checksum = "163b33ed8786455e2fa5d72f554057ce3f3182425434f756cd39c99839d88e23" | ||
| 8055 | + | ||
| 8056 | +[[package]] | ||
| 8057 | +name = "xkbcommon-dl" | ||
| 8058 | +version = "0.4.2" | ||
| 8059 | +source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 8060 | +checksum = "d039de8032a9a8856a6be89cea3e5d12fdd82306ab7c94d74e6deab2460651c5" | ||
| 8061 | +dependencies = [ | ||
| 8062 | + "bitflags 2.13.1", | ||
| 8063 | + "dlib", | ||
| 8064 | + "log", | ||
| 8065 | + "once_cell", | ||
| 8066 | + "xkeysym", | ||
| 8067 | +] | ||
| 8068 | + | ||
| 8069 | +[[package]] | ||
| 8070 | +name = "xkeysym" | ||
| 8071 | +version = "0.2.1" | ||
| 8072 | +source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 8073 | +checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" | ||
| 8074 | + | ||
| 6683 | [[package]] | 8075 | [[package]] |
| 6684 | name = "xml-rs" | 8076 | name = "xml-rs" |
| 6685 | version = "0.8.28" | 8077 | version = "0.8.28" |
| @@ -6837,12 +8229,47 @@ dependencies = [ | |||
| 6837 | "syn 2.0.119", | 8229 | "syn 2.0.119", |
| 6838 | ] | 8230 | ] |
| 6839 | 8231 | ||
| 8232 | +[[package]] | ||
| 8233 | +name = "zip" | ||
| 8234 | +version = "2.4.2" | ||
| 8235 | +source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 8236 | +checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50" | ||
| 8237 | +dependencies = [ | ||
| 8238 | + "arbitrary", | ||
| 8239 | + "crc32fast", | ||
| 8240 | + "crossbeam-utils", | ||
| 8241 | + "displaydoc", | ||
| 8242 | + "flate2", | ||
| 8243 | + "indexmap 2.14.0", | ||
| 8244 | + "memchr", | ||
| 8245 | + "thiserror 2.0.19", | ||
| 8246 | + "zopfli", | ||
| 8247 | +] | ||
| 8248 | + | ||
| 8249 | +[[package]] | ||
| 8250 | +name = "zlib-rs" | ||
| 8251 | +version = "0.6.7" | ||
| 8252 | +source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 8253 | +checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12" | ||
| 8254 | + | ||
| 6840 | [[package]] | 8255 | [[package]] |
| 6841 | name = "zmij" | 8256 | name = "zmij" |
| 6842 | version = "1.0.23" | 8257 | version = "1.0.23" |
| 6843 | source = "registry+https://github.com/rust-lang/crates.io-index" | 8258 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 6844 | checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" | 8259 | checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" |
| 6845 | 8260 | ||
| 8261 | +[[package]] | ||
| 8262 | +name = "zopfli" | ||
| 8263 | +version = "0.8.3" | ||
| 8264 | +source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 8265 | +checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" | ||
| 8266 | +dependencies = [ | ||
| 8267 | + "bumpalo", | ||
| 8268 | + "crc32fast", | ||
| 8269 | + "log", | ||
| 8270 | + "simd-adler32", | ||
| 8271 | +] | ||
| 8272 | + | ||
| 6846 | [[package]] | 8273 | [[package]] |
| 6847 | name = "zune-core" | 8274 | name = "zune-core" |
| 6848 | version = "0.5.1" | 8275 | version = "0.5.1" |
modified
Cargo.toml +12 -1 | @@ -18,7 +18,12 @@ | ||
| 18 | 18 | # no harder than naming one. |
| 19 | 19 | [workspace] |
| 20 | 20 | resolver = "2" |
| 21 | -members = ["crates/jolt-abi", "crates/jolt-moq"] | |
| 21 | +members = [ | |
| 22 | + "crates/jolt-abi", | |
| 23 | + "crates/jolt-moq", | |
| 24 | + "crates/jolt-vidya", | |
| 25 | + "crates/vidya-core", | |
| 26 | +] | |
| 22 | 27 | # The vendored cpal is a patch source, not something this workspace develops. |
| 23 | 28 | # Without excluding it, `cargo test --workspace` builds cpal's own default |
| 24 | 29 | # feature set — which reaches for the Windows ASIO SDK and fails on Linux. |
| @@ -30,6 +35,12 @@ edition = "2021" | ||
| 30 | 35 | license = "MIT" |
| 31 | 36 | repository = "https://gitlab.com/nandithebull/jolt-native" |
| 32 | 37 | |
| 38 | +# vidya-ffi set opt-level 2 when it was its own workspace: the tree walk and | |
| 39 | +# the texture uploads are hot every frame, and a debug build of them is not | |
| 40 | +# worth looking at. A member cannot set this, so it is set here. | |
| 41 | +[profile.release] | |
| 42 | +opt-level = 2 | |
| 43 | + | |
| 33 | 44 | [workspace.dependencies] |
| 34 | 45 | jolt-abi = { path = "crates/jolt-abi" } |
| 35 | 46 | log = "0.4" |
| @@ -18,7 +18,12 @@ | |||
| 18 | # no harder than naming one. | 18 | # no harder than naming one. |
| 19 | [workspace] | 19 | [workspace] |
| 20 | resolver = "2" | 20 | resolver = "2" |
| 21 | -members = ["crates/jolt-abi", "crates/jolt-moq"] | 21 | +members = [ |
| 22 | + "crates/jolt-abi", | ||
| 23 | + "crates/jolt-moq", | ||
| 24 | + "crates/jolt-vidya", | ||
| 25 | + "crates/vidya-core", | ||
| 26 | +] | ||
| 22 | # The vendored cpal is a patch source, not something this workspace develops. | 27 | # The vendored cpal is a patch source, not something this workspace develops. |
| 23 | # Without excluding it, `cargo test --workspace` builds cpal's own default | 28 | # Without excluding it, `cargo test --workspace` builds cpal's own default |
| 24 | # feature set — which reaches for the Windows ASIO SDK and fails on Linux. | 29 | # feature set — which reaches for the Windows ASIO SDK and fails on Linux. |
| @@ -30,6 +35,12 @@ edition = "2021" | |||
| 30 | license = "MIT" | 35 | license = "MIT" |
| 31 | repository = "https://gitlab.com/nandithebull/jolt-native" | 36 | repository = "https://gitlab.com/nandithebull/jolt-native" |
| 32 | 37 | ||
| 38 | +# vidya-ffi set opt-level 2 when it was its own workspace: the tree walk and | ||
| 39 | +# the texture uploads are hot every frame, and a debug build of them is not | ||
| 40 | +# worth looking at. A member cannot set this, so it is set here. | ||
| 41 | +[profile.release] | ||
| 42 | +opt-level = 2 | ||
| 43 | + | ||
| 33 | [workspace.dependencies] | 44 | [workspace.dependencies] |
| 34 | jolt-abi = { path = "crates/jolt-abi" } | 45 | jolt-abi = { path = "crates/jolt-abi" } |
| 35 | log = "0.4" | 46 | log = "0.4" |
modified
README.md +26 -2 | @@ -10,10 +10,17 @@ boundary — the things worth writing in Rust because writing them again in | ||
| 10 | 10 | anything else would mean writing them badly. |
| 11 | 11 | |
| 12 | 12 | ``` |
| 13 | -crates/jolt-abi the rules every library here keeps at its edge | |
| 14 | -crates/jolt-moq freeq's AV media plane — MoQ over QUIC, Opus, H.264, capture | |
| 13 | +crates/jolt-abi the rules every library here keeps at its edge | |
| 14 | +crates/jolt-vidya the retained-tree C ABI → libvidya.so | |
| 15 | +crates/vidya-core the egui semantic layer behind it (theme, widgets, fonts) | |
| 16 | +crates/jolt-moq freeq's AV media plane → libjoltmoq.so | |
| 17 | +jolt/glimmer-vidya the jolt side of libvidya: glimmer's backend | |
| 15 | 18 | ``` |
| 16 | 19 | |
| 20 | +Both objects land in one `target/release`, so a consumer points | |
| 21 | +`LD_LIBRARY_PATH` at one directory and names in `:jolt/native` only the ones it | |
| 22 | +actually wants. | |
| 23 | + | |
| 17 | 24 | ## Why a workspace and not one library with features |
| 18 | 25 | |
| 19 | 26 | Because cargo features are additive *within* a library. A single |
| @@ -101,6 +108,23 @@ Sleek still has its own copy. Pointing it at this crate instead is the obvious | ||
| 101 | 108 | next step and has not been taken yet; until it is, a fix made in one place |
| 102 | 109 | needs making in the other. |
| 103 | 110 | |
| 111 | +## vidya | |
| 112 | + | |
| 113 | +The theme layer, the widget set and the retained-tree ABI that glimmer paints | |
| 114 | +through. Imported from its own repo rather than depended on: egui is the only | |
| 115 | +backend anyone wants now, so the indirection that let `libvidya` be swapped for | |
| 116 | +a raylib or cimgui build of the same symbols was paying for a choice nobody was | |
| 117 | +going to make. | |
| 118 | + | |
| 119 | +`crates/vidya-core` is the egui layer — and the 4MB under its `assets/` is not | |
| 120 | +incidental: the Twemoji pack and the symbols font are `include_bytes!`d into | |
| 121 | +the library, because a shared object has no host app to install a font set for | |
| 122 | +it. | |
| 123 | + | |
| 124 | +`jolt/glimmer-vidya` is the other side of that ABI, and is jolt rather than | |
| 125 | +Rust. A consumer takes it as a `:local/root` dep and `libvidya.so` on the | |
| 126 | +loader path — frq's `deps.edn` is the worked example. | |
| 127 | + | |
| 104 | 128 | ## Adding a crate |
| 105 | 129 | |
| 106 | 130 | One crate, one `.so`, one capability. Depend on `jolt-abi` and keep its three |
| @@ -10,10 +10,17 @@ boundary — the things worth writing in Rust because writing them again in | |||
| 10 | anything else would mean writing them badly. | 10 | anything else would mean writing them badly. |
| 11 | 11 | ||
| 12 | ``` | 12 | ``` |
| 13 | -crates/jolt-abi the rules every library here keeps at its edge | 13 | +crates/jolt-abi the rules every library here keeps at its edge |
| 14 | -crates/jolt-moq freeq's AV media plane — MoQ over QUIC, Opus, H.264, capture | 14 | +crates/jolt-vidya the retained-tree C ABI → libvidya.so |
| 15 | +crates/vidya-core the egui semantic layer behind it (theme, widgets, fonts) | ||
| 16 | +crates/jolt-moq freeq's AV media plane → libjoltmoq.so | ||
| 17 | +jolt/glimmer-vidya the jolt side of libvidya: glimmer's backend | ||
| 15 | ``` | 18 | ``` |
| 16 | 19 | ||
| 20 | +Both objects land in one `target/release`, so a consumer points | ||
| 21 | +`LD_LIBRARY_PATH` at one directory and names in `:jolt/native` only the ones it | ||
| 22 | +actually wants. | ||
| 23 | + | ||
| 17 | ## Why a workspace and not one library with features | 24 | ## Why a workspace and not one library with features |
| 18 | 25 | ||
| 19 | Because cargo features are additive *within* a library. A single | 26 | Because cargo features are additive *within* a library. A single |
| @@ -101,6 +108,23 @@ Sleek still has its own copy. Pointing it at this crate instead is the obvious | |||
| 101 | next step and has not been taken yet; until it is, a fix made in one place | 108 | next step and has not been taken yet; until it is, a fix made in one place |
| 102 | needs making in the other. | 109 | needs making in the other. |
| 103 | 110 | ||
| 111 | +## vidya | ||
| 112 | + | ||
| 113 | +The theme layer, the widget set and the retained-tree ABI that glimmer paints | ||
| 114 | +through. Imported from its own repo rather than depended on: egui is the only | ||
| 115 | +backend anyone wants now, so the indirection that let `libvidya` be swapped for | ||
| 116 | +a raylib or cimgui build of the same symbols was paying for a choice nobody was | ||
| 117 | +going to make. | ||
| 118 | + | ||
| 119 | +`crates/vidya-core` is the egui layer — and the 4MB under its `assets/` is not | ||
| 120 | +incidental: the Twemoji pack and the symbols font are `include_bytes!`d into | ||
| 121 | +the library, because a shared object has no host app to install a font set for | ||
| 122 | +it. | ||
| 123 | + | ||
| 124 | +`jolt/glimmer-vidya` is the other side of that ABI, and is jolt rather than | ||
| 125 | +Rust. A consumer takes it as a `:local/root` dep and `libvidya.so` on the | ||
| 126 | +loader path — frq's `deps.edn` is the worked example. | ||
| 127 | + | ||
| 104 | ## Adding a crate | 128 | ## Adding a crate |
| 105 | 129 | ||
| 106 | One crate, one `.so`, one capability. Depend on `jolt-abi` and keep its three | 130 | One crate, one `.so`, one capability. Depend on `jolt-abi` and keep its three |
added
crates/jolt-vidya/Cargo.toml +40 -0 | new file mode 100644 | ||
| @@ -0,0 +1,40 @@ | ||
| 1 | +[package] | |
| 2 | +name = "vidya-ffi" | |
| 3 | +version = "0.1.0" | |
| 4 | +edition = "2021" | |
| 5 | +description = "Vidya C ABI backed by the Rust egui implementation" | |
| 6 | +license = "MIT" | |
| 7 | +publish = false | |
| 8 | + | |
| 9 | +# Produces `libvidya.so` / `libvidya.dylib`. egui is the only backend now — the | |
| 10 | +# raylib and cimgui ones this name used to be interchangeable with are gone. | |
| 11 | +[lib] | |
| 12 | +name = "vidya" | |
| 13 | +crate-type = ["cdylib"] | |
| 14 | + | |
| 15 | +[dependencies] | |
| 16 | +# The Rust semantic layer this ABI exposes. Renamed so the cdylib itself can | |
| 17 | +# keep the `vidya` lib name. | |
| 18 | +vidya_core = { package = "vidya", path = "../vidya-core" } | |
| 19 | + | |
| 20 | +# `default_fonts` matters here: the root crate turns egui's defaults off, and a | |
| 21 | +# shared library has no host app to install a font set for it. | |
| 22 | +egui = { version = "0.31", features = ["default_fonts"] } | |
| 23 | +egui-winit = "0.31" | |
| 24 | +egui_glow = "0.31" | |
| 25 | +glow = "0.16" | |
| 26 | +# Decoding the images an `:image` node paints. | |
| 27 | +png = "0.17" | |
| 28 | +glutin = "0.32" | |
| 29 | +glutin-winit = "0.5" | |
| 30 | +winit = "0.30" | |
| 31 | + | |
| 32 | +# Reading a pasted picture off the clipboard, which egui does not deliver. | |
| 33 | +# Desktop only: arboard has no Android backend. | |
| 34 | +[target.'cfg(not(target_os = "android"))'.dependencies] | |
| 35 | +arboard = { version = "3", default-features = false, features = ["image-data"] } | |
| 36 | + | |
| 37 | +# `vidya_open_url` is an ACTION_VIEW intent there, which is a JNI call against | |
| 38 | +# the activity the glue holds. Nothing else on this side needs a JVM. | |
| 39 | +[target.'cfg(target_os = "android")'.dependencies] | |
| 40 | +jni = "0.22" | |
| new file mode 100644 | |||
| @@ -0,0 +1,40 @@ | |||
| 1 | +[package] | ||
| 2 | +name = "vidya-ffi" | ||
| 3 | +version = "0.1.0" | ||
| 4 | +edition = "2021" | ||
| 5 | +description = "Vidya C ABI backed by the Rust egui implementation" | ||
| 6 | +license = "MIT" | ||
| 7 | +publish = false | ||
| 8 | + | ||
| 9 | +# Produces `libvidya.so` / `libvidya.dylib`. egui is the only backend now — the | ||
| 10 | +# raylib and cimgui ones this name used to be interchangeable with are gone. | ||
| 11 | +[lib] | ||
| 12 | +name = "vidya" | ||
| 13 | +crate-type = ["cdylib"] | ||
| 14 | + | ||
| 15 | +[dependencies] | ||
| 16 | +# The Rust semantic layer this ABI exposes. Renamed so the cdylib itself can | ||
| 17 | +# keep the `vidya` lib name. | ||
| 18 | +vidya_core = { package = "vidya", path = "../vidya-core" } | ||
| 19 | + | ||
| 20 | +# `default_fonts` matters here: the root crate turns egui's defaults off, and a | ||
| 21 | +# shared library has no host app to install a font set for it. | ||
| 22 | +egui = { version = "0.31", features = ["default_fonts"] } | ||
| 23 | +egui-winit = "0.31" | ||
| 24 | +egui_glow = "0.31" | ||
| 25 | +glow = "0.16" | ||
| 26 | +# Decoding the images an `:image` node paints. | ||
| 27 | +png = "0.17" | ||
| 28 | +glutin = "0.32" | ||
| 29 | +glutin-winit = "0.5" | ||
| 30 | +winit = "0.30" | ||
| 31 | + | ||
| 32 | +# Reading a pasted picture off the clipboard, which egui does not deliver. | ||
| 33 | +# Desktop only: arboard has no Android backend. | ||
| 34 | +[target.'cfg(not(target_os = "android"))'.dependencies] | ||
| 35 | +arboard = { version = "3", default-features = false, features = ["image-data"] } | ||
| 36 | + | ||
| 37 | +# `vidya_open_url` is an ACTION_VIEW intent there, which is a JNI call against | ||
| 38 | +# the activity the glue holds. Nothing else on this side needs a JVM. | ||
| 39 | +[target.'cfg(target_os = "android")'.dependencies] | ||
| 40 | +jni = "0.22" | ||
added
crates/jolt-vidya/include/vidya_tree.h +188 -0 | new file mode 100644 | ||
| @@ -0,0 +1,188 @@ | ||
| 1 | +/* | |
| 2 | + * Vidya's retained-tree ABI — the reactive half. | |
| 3 | + * | |
| 4 | + * `raylib/include/vidya.h` is push/pop: the caller writes its UI out top to | |
| 5 | + * bottom every frame. That suits a program with a frame loop; it does not suit | |
| 6 | + * a reactive toolkit, which keeps a component tree, diffs it, and emits | |
| 7 | + * create/patch/append/remove against native widgets. | |
| 8 | + * | |
| 9 | + * This header is that widget layer. Nodes are integer handles, mutated by the | |
| 10 | + * calls below; nothing is drawn until `vidya_tree_frame`, which paints the | |
| 11 | + * whole tree at once. Interactions come back as a queue of events the caller | |
| 12 | + * drains and routes to its own handlers — a callback cannot cross this | |
| 13 | + * boundary, so identity does instead. | |
| 14 | + * | |
| 15 | + * The two halves share a window: open it with `vidya_open`, set the mode with | |
| 16 | + * `vidya_set_mode`, and drive `vidya_tree_frame` in place of | |
| 17 | + * `vidya_begin_frame` / `vidya_end_frame`. Do not mix them within one frame. | |
| 18 | + * | |
| 19 | + * Only the Rust/egui backend (`ffi/`) implements this header; the C/raylib | |
| 20 | + * backend implements `vidya.h` alone. Every call stays on the thread that | |
| 21 | + * called `vidya_open`, like the rest of the ABI. | |
| 22 | + */ | |
| 23 | +#ifndef VIDYA_TREE_H | |
| 24 | +#define VIDYA_TREE_H | |
| 25 | + | |
| 26 | +#include "vidya.h" | |
| 27 | + | |
| 28 | +#ifdef __cplusplus | |
| 29 | +extern "C" { | |
| 30 | +#endif | |
| 31 | + | |
| 32 | +/* | |
| 33 | + * Node handles are positive; 0 is "no node" — a failed allocation, and the | |
| 34 | + * `sibling` argument that means "first position". | |
| 35 | + * | |
| 36 | + * Tags are hiccup names without the colon: | |
| 37 | + * | |
| 38 | + * containers window box hbox vbox page card frame scroll | |
| 39 | + * widgets label title title-2 dim-label button checkbutton entry | |
| 40 | + * separator spacer progress spinner status | |
| 41 | + * | |
| 42 | + * An unrecognized tag is kept and paints as a vertical box, so a caller ahead | |
| 43 | + * of this backend still sees its children. | |
| 44 | + * | |
| 45 | + * Props are string-keyed, in the same names: | |
| 46 | + * | |
| 47 | + * every node sensitive (bool) | |
| 48 | + * box orientation ("horizontal"|"vertical"), spacing, margin | |
| 49 | + * page max-width | |
| 50 | + * scroll orientation ("vertical"|"horizontal"|"both") | |
| 51 | + * text widgets label, or text | |
| 52 | + * button label, kind ("default"|"primary"|"destructive") | |
| 53 | + * checkbutton label, active (bool) | |
| 54 | + * entry text, placeholder, multiline (bool), rows | |
| 55 | + * spacer size | |
| 56 | + * progress value (0..1), label | |
| 57 | + * status label, live (bool) | |
| 58 | + */ | |
| 59 | + | |
| 60 | +/* The window node, created on first use. Mount everything under it. */ | |
| 61 | +VIDYA_API int vidya_tree_root(void); | |
| 62 | + | |
| 63 | +VIDYA_API int vidya_node_new(const char *tag); | |
| 64 | +VIDYA_API void vidya_node_free(int node); | |
| 65 | +VIDYA_API int vidya_node_exists(int node); | |
| 66 | + | |
| 67 | +VIDYA_API void vidya_node_set_str(int node, const char *key, const char *value); | |
| 68 | +VIDYA_API void vidya_node_set_num(int node, const char *key, double value); | |
| 69 | +VIDYA_API void vidya_node_set_bool(int node, const char *key, int value); | |
| 70 | +VIDYA_API void vidya_node_clear_props(int node); | |
| 71 | + | |
| 72 | +/* | |
| 73 | + * Reads answer the empty string / 0 for a prop that is unset or of another | |
| 74 | + * type. The returned pointer belongs to the library and is valid only until | |
| 75 | + * the next string-returning call on this thread — copy it before the next one. | |
| 76 | + */ | |
| 77 | +VIDYA_API const char *vidya_node_get_str(int node, const char *key); | |
| 78 | +VIDYA_API double vidya_node_get_num(int node, const char *key); | |
| 79 | +VIDYA_API int vidya_node_get_bool(int node, const char *key); | |
| 80 | + | |
| 81 | +/* | |
| 82 | + * Reading the structure back. `vidya_node_tag` answers the canonical tag name | |
| 83 | + * ("box" for both hbox and vbox), under the same borrowed-pointer rule as the | |
| 84 | + * prop reads above; `vidya_node_child_at` answers 0 past the end. | |
| 85 | + */ | |
| 86 | +VIDYA_API const char *vidya_node_tag(int node); | |
| 87 | +VIDYA_API int vidya_node_child_count(int node); | |
| 88 | +VIDYA_API int vidya_node_child_at(int node, int index); | |
| 89 | + | |
| 90 | +/* | |
| 91 | + * The subtree at `node` as pretty-printed hiccup — `[:tag {props} children...]`, | |
| 92 | + * one node to a line — for logging, tests, and bug reports. `node` 0 means the | |
| 93 | + * root, so `vidya_tree_dump(0)` is the whole window. Borrowed pointer, under | |
| 94 | + * the same rule as the reads above. | |
| 95 | + * | |
| 96 | + * It answers what the tree *is*, read back from the arena, rather than what a | |
| 97 | + * caller meant to build. Two caveats: `hbox` and `vbox` both dump as `box`, | |
| 98 | + * with the orientation in the props, and handlers are absent because they | |
| 99 | + * never crossed this boundary. | |
| 100 | + */ | |
| 101 | +VIDYA_API const char *vidya_tree_dump(int node); | |
| 102 | + | |
| 103 | +VIDYA_API int vidya_node_append(int parent, int child); | |
| 104 | +/* Unparents AND frees `child` with everything under it. */ | |
| 105 | +VIDYA_API void vidya_node_remove(int parent, int child); | |
| 106 | +/* Moves `child` after `sibling`; `sibling` 0 means the first position. */ | |
| 107 | +VIDYA_API int vidya_node_insert_after(int parent, int child, int sibling); | |
| 108 | +/* Puts `new_child` where `old_child` was, and frees `old_child`. */ | |
| 109 | +VIDYA_API int vidya_node_replace(int parent, int old_child, int new_child); | |
| 110 | + | |
| 111 | +/* Paint the whole tree as one frame. Inert with no window open. */ | |
| 112 | +VIDYA_API void vidya_tree_frame(void); | |
| 113 | + | |
| 114 | +/* | |
| 115 | + * Drain interactions. `vidya_tree_poll_event` dequeues one and answers 1 while | |
| 116 | + * there was one; the accessors describe whichever was dequeued last. | |
| 117 | + * | |
| 118 | + * Event names are glimmer's handler props without the `on-`: | |
| 119 | + * | |
| 120 | + * click a button was pressed no payload | |
| 121 | + * toggled a checkbutton changed num is the new state | |
| 122 | + * change an entry's text changed text is the new text | |
| 123 | + * activate Enter was pressed in an entry no payload | |
| 124 | + * | |
| 125 | + * A widget does not own its value: `toggled` and `change` write the new state | |
| 126 | + * back into the node's props as well, so a caller that ignores the event still | |
| 127 | + * sees a working control, and the next prop write is what settles it. | |
| 128 | + */ | |
| 129 | +VIDYA_API int vidya_tree_poll_event(void); | |
| 130 | +VIDYA_API int vidya_tree_event_node(void); | |
| 131 | +VIDYA_API const char *vidya_tree_event_name(void); | |
| 132 | +VIDYA_API const char *vidya_tree_event_text(void); | |
| 133 | +VIDYA_API double vidya_tree_event_num(void); | |
| 134 | + | |
| 135 | +/* Live frames | |
| 136 | + * | |
| 137 | + * Hand the tree a frame of raw pixels under `key`; any `:image` node whose | |
| 138 | + * `feed` prop names it paints the latest one. Returns 1 when accepted. | |
| 139 | + * | |
| 140 | + * This is what `src` cannot do: a `src` image decodes a file and caches the | |
| 141 | + * texture by its path for the life of the process, which is right for a | |
| 142 | + * picture in a message and useless for a source that makes a new one thirty | |
| 143 | + * times a second. A caller with its own pixels — a camera, a video decoder, a | |
| 144 | + * renderer — pushes them here instead. | |
| 145 | + * | |
| 146 | + * `rgba` is width * height * 4 bytes, row-major, 8 bits a channel, | |
| 147 | + * un-premultiplied. It is copied before the call returns; the caller may reuse | |
| 148 | + * the buffer immediately. A length disagreeing with the dimensions is refused. | |
| 149 | + * | |
| 150 | + * Frames coalesce rather than queue: one arriving before the last was painted | |
| 151 | + * replaces it, so a source faster than the window builds no backlog. | |
| 152 | + * | |
| 153 | + * Like the rest of this ABI, on the window's thread. `vidya_frame_drop` | |
| 154 | + * forgets a feed and releases its texture — without it the last frame of a | |
| 155 | + * source that has stopped keeps painting. | |
| 156 | + */ | |
| 157 | +VIDYA_API int vidya_frame_rgba(const char *key, int width, int height, | |
| 158 | + const unsigned char *rgba); | |
| 159 | +VIDYA_API int vidya_frame_drop(const char *key); | |
| 160 | + | |
| 161 | +/* Clipboard | |
| 162 | + * | |
| 163 | + * Write the picture on the system clipboard to `path` as a PNG; returns 1 when | |
| 164 | + * there was one and it was written, 0 otherwise (an empty clipboard, text on | |
| 165 | + * it, an unwritable path, or a platform with no image clipboard at all). | |
| 166 | + * | |
| 167 | + * A pasted image arrives through no event — egui carries only clipboard text | |
| 168 | + * into a frame — so this is asked rather than waited for: bind it to whatever | |
| 169 | + * gesture means paste, and read the file it names. Needs no window, and unlike | |
| 170 | + * the rest of this ABI is not tied to the window's thread. | |
| 171 | + */ | |
| 172 | +VIDYA_API int vidya_clipboard_image_png(const char *path); | |
| 173 | + | |
| 174 | +/* Open a URL | |
| 175 | + * | |
| 176 | + * Hand `url` to whatever shows web pages here — xdg-open/open on the desktop, | |
| 177 | + * an ACTION_VIEW intent on Android; returns 1 when something took it and 0 | |
| 178 | + * otherwise, in which case show the URL and let the reader carry it across. | |
| 179 | + * | |
| 180 | + * Like the clipboard call above it needs no window and no particular thread. | |
| 181 | + */ | |
| 182 | +VIDYA_API int vidya_open_url(const char *url); | |
| 183 | + | |
| 184 | +#ifdef __cplusplus | |
| 185 | +} | |
| 186 | +#endif | |
| 187 | + | |
| 188 | +#endif /* VIDYA_TREE_H */ | |
| new file mode 100644 | |||
| @@ -0,0 +1,188 @@ | |||
| 1 | +/* | ||
| 2 | + * Vidya's retained-tree ABI — the reactive half. | ||
| 3 | + * | ||
| 4 | + * `raylib/include/vidya.h` is push/pop: the caller writes its UI out top to | ||
| 5 | + * bottom every frame. That suits a program with a frame loop; it does not suit | ||
| 6 | + * a reactive toolkit, which keeps a component tree, diffs it, and emits | ||
| 7 | + * create/patch/append/remove against native widgets. | ||
| 8 | + * | ||
| 9 | + * This header is that widget layer. Nodes are integer handles, mutated by the | ||
| 10 | + * calls below; nothing is drawn until `vidya_tree_frame`, which paints the | ||
| 11 | + * whole tree at once. Interactions come back as a queue of events the caller | ||
| 12 | + * drains and routes to its own handlers — a callback cannot cross this | ||
| 13 | + * boundary, so identity does instead. | ||
| 14 | + * | ||
| 15 | + * The two halves share a window: open it with `vidya_open`, set the mode with | ||
| 16 | + * `vidya_set_mode`, and drive `vidya_tree_frame` in place of | ||
| 17 | + * `vidya_begin_frame` / `vidya_end_frame`. Do not mix them within one frame. | ||
| 18 | + * | ||
| 19 | + * Only the Rust/egui backend (`ffi/`) implements this header; the C/raylib | ||
| 20 | + * backend implements `vidya.h` alone. Every call stays on the thread that | ||
| 21 | + * called `vidya_open`, like the rest of the ABI. | ||
| 22 | + */ | ||
| 23 | +#ifndef VIDYA_TREE_H | ||
| 24 | +#define VIDYA_TREE_H | ||
| 25 | + | ||
| 26 | +#include "vidya.h" | ||
| 27 | + | ||
| 28 | +#ifdef __cplusplus | ||
| 29 | +extern "C" { | ||
| 30 | +#endif | ||
| 31 | + | ||
| 32 | +/* | ||
| 33 | + * Node handles are positive; 0 is "no node" — a failed allocation, and the | ||
| 34 | + * `sibling` argument that means "first position". | ||
| 35 | + * | ||
| 36 | + * Tags are hiccup names without the colon: | ||
| 37 | + * | ||
| 38 | + * containers window box hbox vbox page card frame scroll | ||
| 39 | + * widgets label title title-2 dim-label button checkbutton entry | ||
| 40 | + * separator spacer progress spinner status | ||
| 41 | + * | ||
| 42 | + * An unrecognized tag is kept and paints as a vertical box, so a caller ahead | ||
| 43 | + * of this backend still sees its children. | ||
| 44 | + * | ||
| 45 | + * Props are string-keyed, in the same names: | ||
| 46 | + * | ||
| 47 | + * every node sensitive (bool) | ||
| 48 | + * box orientation ("horizontal"|"vertical"), spacing, margin | ||
| 49 | + * page max-width | ||
| 50 | + * scroll orientation ("vertical"|"horizontal"|"both") | ||
| 51 | + * text widgets label, or text | ||
| 52 | + * button label, kind ("default"|"primary"|"destructive") | ||
| 53 | + * checkbutton label, active (bool) | ||
| 54 | + * entry text, placeholder, multiline (bool), rows | ||
| 55 | + * spacer size | ||
| 56 | + * progress value (0..1), label | ||
| 57 | + * status label, live (bool) | ||
| 58 | + */ | ||
| 59 | + | ||
| 60 | +/* The window node, created on first use. Mount everything under it. */ | ||
| 61 | +VIDYA_API int vidya_tree_root(void); | ||
| 62 | + | ||
| 63 | +VIDYA_API int vidya_node_new(const char *tag); | ||
| 64 | +VIDYA_API void vidya_node_free(int node); | ||
| 65 | +VIDYA_API int vidya_node_exists(int node); | ||
| 66 | + | ||
| 67 | +VIDYA_API void vidya_node_set_str(int node, const char *key, const char *value); | ||
| 68 | +VIDYA_API void vidya_node_set_num(int node, const char *key, double value); | ||
| 69 | +VIDYA_API void vidya_node_set_bool(int node, const char *key, int value); | ||
| 70 | +VIDYA_API void vidya_node_clear_props(int node); | ||
| 71 | + | ||
| 72 | +/* | ||
| 73 | + * Reads answer the empty string / 0 for a prop that is unset or of another | ||
| 74 | + * type. The returned pointer belongs to the library and is valid only until | ||
| 75 | + * the next string-returning call on this thread — copy it before the next one. | ||
| 76 | + */ | ||
| 77 | +VIDYA_API const char *vidya_node_get_str(int node, const char *key); | ||
| 78 | +VIDYA_API double vidya_node_get_num(int node, const char *key); | ||
| 79 | +VIDYA_API int vidya_node_get_bool(int node, const char *key); | ||
| 80 | + | ||
| 81 | +/* | ||
| 82 | + * Reading the structure back. `vidya_node_tag` answers the canonical tag name | ||
| 83 | + * ("box" for both hbox and vbox), under the same borrowed-pointer rule as the | ||
| 84 | + * prop reads above; `vidya_node_child_at` answers 0 past the end. | ||
| 85 | + */ | ||
| 86 | +VIDYA_API const char *vidya_node_tag(int node); | ||
| 87 | +VIDYA_API int vidya_node_child_count(int node); | ||
| 88 | +VIDYA_API int vidya_node_child_at(int node, int index); | ||
| 89 | + | ||
| 90 | +/* | ||
| 91 | + * The subtree at `node` as pretty-printed hiccup — `[:tag {props} children...]`, | ||
| 92 | + * one node to a line — for logging, tests, and bug reports. `node` 0 means the | ||
| 93 | + * root, so `vidya_tree_dump(0)` is the whole window. Borrowed pointer, under | ||
| 94 | + * the same rule as the reads above. | ||
| 95 | + * | ||
| 96 | + * It answers what the tree *is*, read back from the arena, rather than what a | ||
| 97 | + * caller meant to build. Two caveats: `hbox` and `vbox` both dump as `box`, | ||
| 98 | + * with the orientation in the props, and handlers are absent because they | ||
| 99 | + * never crossed this boundary. | ||
| 100 | + */ | ||
| 101 | +VIDYA_API const char *vidya_tree_dump(int node); | ||
| 102 | + | ||
| 103 | +VIDYA_API int vidya_node_append(int parent, int child); | ||
| 104 | +/* Unparents AND frees `child` with everything under it. */ | ||
| 105 | +VIDYA_API void vidya_node_remove(int parent, int child); | ||
| 106 | +/* Moves `child` after `sibling`; `sibling` 0 means the first position. */ | ||
| 107 | +VIDYA_API int vidya_node_insert_after(int parent, int child, int sibling); | ||
| 108 | +/* Puts `new_child` where `old_child` was, and frees `old_child`. */ | ||
| 109 | +VIDYA_API int vidya_node_replace(int parent, int old_child, int new_child); | ||
| 110 | + | ||
| 111 | +/* Paint the whole tree as one frame. Inert with no window open. */ | ||
| 112 | +VIDYA_API void vidya_tree_frame(void); | ||
| 113 | + | ||
| 114 | +/* | ||
| 115 | + * Drain interactions. `vidya_tree_poll_event` dequeues one and answers 1 while | ||
| 116 | + * there was one; the accessors describe whichever was dequeued last. | ||
| 117 | + * | ||
| 118 | + * Event names are glimmer's handler props without the `on-`: | ||
| 119 | + * | ||
| 120 | + * click a button was pressed no payload | ||
| 121 | + * toggled a checkbutton changed num is the new state | ||
| 122 | + * change an entry's text changed text is the new text | ||
| 123 | + * activate Enter was pressed in an entry no payload | ||
| 124 | + * | ||
| 125 | + * A widget does not own its value: `toggled` and `change` write the new state | ||
| 126 | + * back into the node's props as well, so a caller that ignores the event still | ||
| 127 | + * sees a working control, and the next prop write is what settles it. | ||
| 128 | + */ | ||
| 129 | +VIDYA_API int vidya_tree_poll_event(void); | ||
| 130 | +VIDYA_API int vidya_tree_event_node(void); | ||
| 131 | +VIDYA_API const char *vidya_tree_event_name(void); | ||
| 132 | +VIDYA_API const char *vidya_tree_event_text(void); | ||
| 133 | +VIDYA_API double vidya_tree_event_num(void); | ||
| 134 | + | ||
| 135 | +/* Live frames | ||
| 136 | + * | ||
| 137 | + * Hand the tree a frame of raw pixels under `key`; any `:image` node whose | ||
| 138 | + * `feed` prop names it paints the latest one. Returns 1 when accepted. | ||
| 139 | + * | ||
| 140 | + * This is what `src` cannot do: a `src` image decodes a file and caches the | ||
| 141 | + * texture by its path for the life of the process, which is right for a | ||
| 142 | + * picture in a message and useless for a source that makes a new one thirty | ||
| 143 | + * times a second. A caller with its own pixels — a camera, a video decoder, a | ||
| 144 | + * renderer — pushes them here instead. | ||
| 145 | + * | ||
| 146 | + * `rgba` is width * height * 4 bytes, row-major, 8 bits a channel, | ||
| 147 | + * un-premultiplied. It is copied before the call returns; the caller may reuse | ||
| 148 | + * the buffer immediately. A length disagreeing with the dimensions is refused. | ||
| 149 | + * | ||
| 150 | + * Frames coalesce rather than queue: one arriving before the last was painted | ||
| 151 | + * replaces it, so a source faster than the window builds no backlog. | ||
| 152 | + * | ||
| 153 | + * Like the rest of this ABI, on the window's thread. `vidya_frame_drop` | ||
| 154 | + * forgets a feed and releases its texture — without it the last frame of a | ||
| 155 | + * source that has stopped keeps painting. | ||
| 156 | + */ | ||
| 157 | +VIDYA_API int vidya_frame_rgba(const char *key, int width, int height, | ||
| 158 | + const unsigned char *rgba); | ||
| 159 | +VIDYA_API int vidya_frame_drop(const char *key); | ||
| 160 | + | ||
| 161 | +/* Clipboard | ||
| 162 | + * | ||
| 163 | + * Write the picture on the system clipboard to `path` as a PNG; returns 1 when | ||
| 164 | + * there was one and it was written, 0 otherwise (an empty clipboard, text on | ||
| 165 | + * it, an unwritable path, or a platform with no image clipboard at all). | ||
| 166 | + * | ||
| 167 | + * A pasted image arrives through no event — egui carries only clipboard text | ||
| 168 | + * into a frame — so this is asked rather than waited for: bind it to whatever | ||
| 169 | + * gesture means paste, and read the file it names. Needs no window, and unlike | ||
| 170 | + * the rest of this ABI is not tied to the window's thread. | ||
| 171 | + */ | ||
| 172 | +VIDYA_API int vidya_clipboard_image_png(const char *path); | ||
| 173 | + | ||
| 174 | +/* Open a URL | ||
| 175 | + * | ||
| 176 | + * Hand `url` to whatever shows web pages here — xdg-open/open on the desktop, | ||
| 177 | + * an ACTION_VIEW intent on Android; returns 1 when something took it and 0 | ||
| 178 | + * otherwise, in which case show the URL and let the reader carry it across. | ||
| 179 | + * | ||
| 180 | + * Like the clipboard call above it needs no window and no particular thread. | ||
| 181 | + */ | ||
| 182 | +VIDYA_API int vidya_open_url(const char *url); | ||
| 183 | + | ||
| 184 | +#ifdef __cplusplus | ||
| 185 | +} | ||
| 186 | +#endif | ||
| 187 | + | ||
| 188 | +#endif /* VIDYA_TREE_H */ | ||
added
crates/jolt-vidya/src/android.rs +123 -0 | new file mode 100644 | ||
| @@ -0,0 +1,123 @@ | ||
| 1 | +//! The Android entry point, and the `AndroidApp` the frame loop needs. | |
| 2 | +//! | |
| 3 | +//! On a desktop the caller owns `main` and this library is just a dependency. | |
| 4 | +//! Android inverts that: the activity is created by the platform, and whoever | |
| 5 | +//! owns the activity owns the event loop. winit cannot build one on Android | |
| 6 | +//! without the `AndroidApp` handle that `android-activity`'s glue receives, and | |
| 7 | +//! that glue only runs if *this* library is the NativeActivity's library. | |
| 8 | +//! | |
| 9 | +//! So on Android the roles swap. `libvidya.so` is the entry point named by the | |
| 10 | +//! manifest, [`android_main`] stashes the handle [`build_event_loop`] later | |
| 11 | +//! needs, and then hands control to the application — an embedded Jolt boot | |
| 12 | +//! image in `libjoltapp.so`, which calls straight back into the C ABI below. | |
| 13 | +//! | |
| 14 | +//! The application is reached by `dlopen` rather than by linking. Linking would | |
| 15 | +//! be a cycle — `libjoltapp.so` already needs this library for every `vidya_*` | |
| 16 | +//! symbol it registers with Chez — and Android's loader resolves a dlopened | |
| 17 | +//! library's dependencies without one. | |
| 18 | + | |
| 19 | +use std::ffi::{c_char, c_int, c_void, CString}; | |
| 20 | +use std::sync::Mutex; | |
| 21 | + | |
| 22 | +use winit::platform::android::activity::AndroidApp; | |
| 23 | + | |
| 24 | +/// The library holding the application's `vidya_jolt_main`. | |
| 25 | +const APP_LIBRARY: &str = "libjoltapp.so"; | |
| 26 | + | |
| 27 | +/// Set once, before any application code runs, and read on the loop thread. | |
| 28 | +static ANDROID_APP: Mutex<Option<AndroidApp>> = Mutex::new(None); | |
| 29 | + | |
| 30 | +/// The handle winit needs to build an event loop. `None` off Android's own | |
| 31 | +/// thread, or before the glue has started. | |
| 32 | +pub fn android_app() -> Option<AndroidApp> { | |
| 33 | + ANDROID_APP.lock().ok()?.clone() | |
| 34 | +} | |
| 35 | + | |
| 36 | +extern "C" { | |
| 37 | + fn dlopen(filename: *const c_char, flag: c_int) -> *mut c_void; | |
| 38 | + fn dlsym(handle: *mut c_void, symbol: *const c_char) -> *mut c_void; | |
| 39 | + fn dlerror() -> *const c_char; | |
| 40 | + fn __android_log_write(prio: c_int, tag: *const c_char, text: *const c_char) -> c_int; | |
| 41 | +} | |
| 42 | + | |
| 43 | +const ANDROID_LOG_INFO: c_int = 4; | |
| 44 | +const ANDROID_LOG_ERROR: c_int = 6; | |
| 45 | +const RTLD_NOW: c_int = 2; | |
| 46 | + | |
| 47 | +/// What went wrong on the way to a platform call, for `adb logcat -s Vidya`. | |
| 48 | +pub(crate) fn warn(message: &str) { | |
| 49 | + log(ANDROID_LOG_ERROR, message); | |
| 50 | +} | |
| 51 | + | |
| 52 | +fn log(priority: c_int, message: &str) { | |
| 53 | + let (Ok(tag), Ok(text)) = (CString::new("Vidya"), CString::new(message)) else { | |
| 54 | + return; | |
| 55 | + }; | |
| 56 | + // SAFETY: both pointers are NUL-terminated and live across the call. | |
| 57 | + unsafe { __android_log_write(priority, tag.as_ptr(), text.as_ptr()) }; | |
| 58 | +} | |
| 59 | + | |
| 60 | +fn last_dl_error() -> String { | |
| 61 | + // SAFETY: dlerror returns a borrowed C string, or null when nothing failed. | |
| 62 | + let err = unsafe { dlerror() }; | |
| 63 | + if err.is_null() { | |
| 64 | + return "unknown error".to_owned(); | |
| 65 | + } | |
| 66 | + unsafe { std::ffi::CStr::from_ptr(err) } | |
| 67 | + .to_string_lossy() | |
| 68 | + .into_owned() | |
| 69 | +} | |
| 70 | + | |
| 71 | +/// Entry point for `android-activity`'s NativeActivity glue. | |
| 72 | +/// | |
| 73 | +/// Returning from here finishes the activity, which looks like a one-frame | |
| 74 | +/// flash, so a failure to reach the application is logged rather than silent. | |
| 75 | +#[no_mangle] | |
| 76 | +pub extern "C" fn android_main(app: AndroidApp) { | |
| 77 | + if let Ok(mut slot) = ANDROID_APP.lock() { | |
| 78 | + *slot = Some(app); | |
| 79 | + } | |
| 80 | + | |
| 81 | + log(ANDROID_LOG_INFO, "vidya: loading the Jolt application"); | |
| 82 | + | |
| 83 | + let Ok(name) = CString::new(APP_LIBRARY) else { | |
| 84 | + return; | |
| 85 | + }; | |
| 86 | + // SAFETY: `name` is a NUL-terminated library name; the handle is only used | |
| 87 | + // to look up one symbol and is deliberately never closed — the application | |
| 88 | + // runs for the lifetime of the process. | |
| 89 | + let handle = unsafe { dlopen(name.as_ptr(), RTLD_NOW) }; | |
| 90 | + if handle.is_null() { | |
| 91 | + log( | |
| 92 | + ANDROID_LOG_ERROR, | |
| 93 | + &format!("vidya: cannot load {APP_LIBRARY}: {}", last_dl_error()), | |
| 94 | + ); | |
| 95 | + return; | |
| 96 | + } | |
| 97 | + | |
| 98 | + let Ok(symbol) = CString::new("vidya_jolt_main") else { | |
| 99 | + return; | |
| 100 | + }; | |
| 101 | + // SAFETY: as above; the result is checked before it is called. | |
| 102 | + let entry = unsafe { dlsym(handle, symbol.as_ptr()) }; | |
| 103 | + if entry.is_null() { | |
| 104 | + log( | |
| 105 | + ANDROID_LOG_ERROR, | |
| 106 | + &format!( | |
| 107 | + "vidya: {APP_LIBRARY} has no vidya_jolt_main: {}", | |
| 108 | + last_dl_error() | |
| 109 | + ), | |
| 110 | + ); | |
| 111 | + return; | |
| 112 | + } | |
| 113 | + | |
| 114 | + // SAFETY: `vidya_jolt_main` is declared by the glue in android/jolt_main.c | |
| 115 | + // as `int vidya_jolt_main(void)`; it boots Chez and does not return until | |
| 116 | + // the application exits. | |
| 117 | + let entry: extern "C" fn() -> c_int = unsafe { std::mem::transmute(entry) }; | |
| 118 | + let status = entry(); | |
| 119 | + log( | |
| 120 | + ANDROID_LOG_INFO, | |
| 121 | + &format!("vidya: Jolt application exited with status {status}"), | |
| 122 | + ); | |
| 123 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,123 @@ | |||
| 1 | +//! The Android entry point, and the `AndroidApp` the frame loop needs. | ||
| 2 | +//! | ||
| 3 | +//! On a desktop the caller owns `main` and this library is just a dependency. | ||
| 4 | +//! Android inverts that: the activity is created by the platform, and whoever | ||
| 5 | +//! owns the activity owns the event loop. winit cannot build one on Android | ||
| 6 | +//! without the `AndroidApp` handle that `android-activity`'s glue receives, and | ||
| 7 | +//! that glue only runs if *this* library is the NativeActivity's library. | ||
| 8 | +//! | ||
| 9 | +//! So on Android the roles swap. `libvidya.so` is the entry point named by the | ||
| 10 | +//! manifest, [`android_main`] stashes the handle [`build_event_loop`] later | ||
| 11 | +//! needs, and then hands control to the application — an embedded Jolt boot | ||
| 12 | +//! image in `libjoltapp.so`, which calls straight back into the C ABI below. | ||
| 13 | +//! | ||
| 14 | +//! The application is reached by `dlopen` rather than by linking. Linking would | ||
| 15 | +//! be a cycle — `libjoltapp.so` already needs this library for every `vidya_*` | ||
| 16 | +//! symbol it registers with Chez — and Android's loader resolves a dlopened | ||
| 17 | +//! library's dependencies without one. | ||
| 18 | + | ||
| 19 | +use std::ffi::{c_char, c_int, c_void, CString}; | ||
| 20 | +use std::sync::Mutex; | ||
| 21 | + | ||
| 22 | +use winit::platform::android::activity::AndroidApp; | ||
| 23 | + | ||
| 24 | +/// The library holding the application's `vidya_jolt_main`. | ||
| 25 | +const APP_LIBRARY: &str = "libjoltapp.so"; | ||
| 26 | + | ||
| 27 | +/// Set once, before any application code runs, and read on the loop thread. | ||
| 28 | +static ANDROID_APP: Mutex<Option<AndroidApp>> = Mutex::new(None); | ||
| 29 | + | ||
| 30 | +/// The handle winit needs to build an event loop. `None` off Android's own | ||
| 31 | +/// thread, or before the glue has started. | ||
| 32 | +pub fn android_app() -> Option<AndroidApp> { | ||
| 33 | + ANDROID_APP.lock().ok()?.clone() | ||
| 34 | +} | ||
| 35 | + | ||
| 36 | +extern "C" { | ||
| 37 | + fn dlopen(filename: *const c_char, flag: c_int) -> *mut c_void; | ||
| 38 | + fn dlsym(handle: *mut c_void, symbol: *const c_char) -> *mut c_void; | ||
| 39 | + fn dlerror() -> *const c_char; | ||
| 40 | + fn __android_log_write(prio: c_int, tag: *const c_char, text: *const c_char) -> c_int; | ||
| 41 | +} | ||
| 42 | + | ||
| 43 | +const ANDROID_LOG_INFO: c_int = 4; | ||
| 44 | +const ANDROID_LOG_ERROR: c_int = 6; | ||
| 45 | +const RTLD_NOW: c_int = 2; | ||
| 46 | + | ||
| 47 | +/// What went wrong on the way to a platform call, for `adb logcat -s Vidya`. | ||
| 48 | +pub(crate) fn warn(message: &str) { | ||
| 49 | + log(ANDROID_LOG_ERROR, message); | ||
| 50 | +} | ||
| 51 | + | ||
| 52 | +fn log(priority: c_int, message: &str) { | ||
| 53 | + let (Ok(tag), Ok(text)) = (CString::new("Vidya"), CString::new(message)) else { | ||
| 54 | + return; | ||
| 55 | + }; | ||
| 56 | + // SAFETY: both pointers are NUL-terminated and live across the call. | ||
| 57 | + unsafe { __android_log_write(priority, tag.as_ptr(), text.as_ptr()) }; | ||
| 58 | +} | ||
| 59 | + | ||
| 60 | +fn last_dl_error() -> String { | ||
| 61 | + // SAFETY: dlerror returns a borrowed C string, or null when nothing failed. | ||
| 62 | + let err = unsafe { dlerror() }; | ||
| 63 | + if err.is_null() { | ||
| 64 | + return "unknown error".to_owned(); | ||
| 65 | + } | ||
| 66 | + unsafe { std::ffi::CStr::from_ptr(err) } | ||
| 67 | + .to_string_lossy() | ||
| 68 | + .into_owned() | ||
| 69 | +} | ||
| 70 | + | ||
| 71 | +/// Entry point for `android-activity`'s NativeActivity glue. | ||
| 72 | +/// | ||
| 73 | +/// Returning from here finishes the activity, which looks like a one-frame | ||
| 74 | +/// flash, so a failure to reach the application is logged rather than silent. | ||
| 75 | +#[no_mangle] | ||
| 76 | +pub extern "C" fn android_main(app: AndroidApp) { | ||
| 77 | + if let Ok(mut slot) = ANDROID_APP.lock() { | ||
| 78 | + *slot = Some(app); | ||
| 79 | + } | ||
| 80 | + | ||
| 81 | + log(ANDROID_LOG_INFO, "vidya: loading the Jolt application"); | ||
| 82 | + | ||
| 83 | + let Ok(name) = CString::new(APP_LIBRARY) else { | ||
| 84 | + return; | ||
| 85 | + }; | ||
| 86 | + // SAFETY: `name` is a NUL-terminated library name; the handle is only used | ||
| 87 | + // to look up one symbol and is deliberately never closed — the application | ||
| 88 | + // runs for the lifetime of the process. | ||
| 89 | + let handle = unsafe { dlopen(name.as_ptr(), RTLD_NOW) }; | ||
| 90 | + if handle.is_null() { | ||
| 91 | + log( | ||
| 92 | + ANDROID_LOG_ERROR, | ||
| 93 | + &format!("vidya: cannot load {APP_LIBRARY}: {}", last_dl_error()), | ||
| 94 | + ); | ||
| 95 | + return; | ||
| 96 | + } | ||
| 97 | + | ||
| 98 | + let Ok(symbol) = CString::new("vidya_jolt_main") else { | ||
| 99 | + return; | ||
| 100 | + }; | ||
| 101 | + // SAFETY: as above; the result is checked before it is called. | ||
| 102 | + let entry = unsafe { dlsym(handle, symbol.as_ptr()) }; | ||
| 103 | + if entry.is_null() { | ||
| 104 | + log( | ||
| 105 | + ANDROID_LOG_ERROR, | ||
| 106 | + &format!( | ||
| 107 | + "vidya: {APP_LIBRARY} has no vidya_jolt_main: {}", | ||
| 108 | + last_dl_error() | ||
| 109 | + ), | ||
| 110 | + ); | ||
| 111 | + return; | ||
| 112 | + } | ||
| 113 | + | ||
| 114 | + // SAFETY: `vidya_jolt_main` is declared by the glue in android/jolt_main.c | ||
| 115 | + // as `int vidya_jolt_main(void)`; it boots Chez and does not return until | ||
| 116 | + // the application exits. | ||
| 117 | + let entry: extern "C" fn() -> c_int = unsafe { std::mem::transmute(entry) }; | ||
| 118 | + let status = entry(); | ||
| 119 | + log( | ||
| 120 | + ANDROID_LOG_INFO, | ||
| 121 | + &format!("vidya: Jolt application exited with status {status}"), | ||
| 122 | + ); | ||
| 123 | +} | ||
added
crates/jolt-vidya/src/app.rs +721 -0 | new file mode 100644 | ||
| @@ -0,0 +1,721 @@ | ||
| 1 | +//! Window, GL context, and the caller-driven frame loop. | |
| 2 | +//! | |
| 3 | +//! The C ABI is pull-style: the caller owns the loop and calls | |
| 4 | +//! [`App::begin_frame`] / [`App::end_frame`] around its own widget calls. | |
| 5 | +//! `eframe` inverts that — it owns the loop and calls the app — so this backend | |
| 6 | +//! drives `winit` with `pump_app_events` and paints through `egui_glow`, | |
| 7 | +//! keeping the ABI (and every consumer of it) unchanged. | |
| 8 | +//! | |
| 9 | +//! One thing does **not** get to move outside the event loop: the present. | |
| 10 | +//! Wayland gives a surface one buffer per frame callback, and winit tracks | |
| 11 | +//! those callbacks itself. Presenting from outside its `RedrawRequested` | |
| 12 | +//! dispatch desynchronizes that bookkeeping — the compositor stops calling | |
| 13 | +//! back, the next `swap_buffers` blocks inside EGL with the session waiting on | |
| 14 | +//! it, and the whole desktop stalls for seconds. So [`App::end_frame`] hands | |
| 15 | +//! the finished frame to [`Handler`] and pumps until it is painted from inside | |
| 16 | +//! the callback, which is also what paces the caller's loop. | |
| 17 | + | |
| 18 | +use std::sync::Arc; | |
| 19 | +use std::time::{Duration, Instant}; | |
| 20 | + | |
| 21 | +use egui::ViewportId; | |
| 22 | +use glutin::config::{Config, ConfigTemplateBuilder}; | |
| 23 | +use glutin::context::{ContextApi, ContextAttributesBuilder, PossiblyCurrentContext}; | |
| 24 | +use glutin::display::GetGlDisplay as _; | |
| 25 | +use glutin::prelude::*; | |
| 26 | +use glutin::surface::{Surface, SurfaceAttributesBuilder, SwapInterval, WindowSurface}; | |
| 27 | +use glutin_winit::{DisplayBuilder, GlWindow as _}; | |
| 28 | +use vidya_core::Theme; | |
| 29 | +use winit::application::ApplicationHandler; | |
| 30 | +use winit::dpi::LogicalSize; | |
| 31 | +use winit::event::WindowEvent; | |
| 32 | +use winit::event_loop::{ActiveEventLoop, EventLoop}; | |
| 33 | +use winit::platform::pump_events::EventLoopExtPumpEvents as _; | |
| 34 | +use winit::raw_window_handle::HasWindowHandle as _; | |
| 35 | +use winit::window::{Window, WindowId}; | |
| 36 | + | |
| 37 | +use crate::ui::Stack; | |
| 38 | + | |
| 39 | +/// How long `vidya_open` waits for the platform to hand us a window. | |
| 40 | +const OPEN_TIMEOUT: Duration = Duration::from_secs(5); | |
| 41 | + | |
| 42 | +/// How long a finished frame waits for a frame callback before being dropped. | |
| 43 | +/// A window nobody is compositing (minimized, on another workspace) simply | |
| 44 | +/// stops presenting; the caller's loop keeps running. | |
| 45 | +const PRESENT_TIMEOUT: Duration = Duration::from_millis(250); | |
| 46 | + | |
| 47 | +/// Pacing floor, so a caller that never sets a target FPS still yields. | |
| 48 | +const DEFAULT_FRAME_BUDGET: Duration = Duration::from_micros(16_666); | |
| 49 | + | |
| 50 | +/// Build the event loop, preferring X11 where both backends exist. | |
| 51 | +/// | |
| 52 | +/// Native Wayland does not survive this ABI's shape: the caller owns the loop, | |
| 53 | +/// so winit is driven with `pump_app_events`, and a surface driven that way | |
| 54 | +/// stops receiving frame callbacks after its first commit — the window never | |
| 55 | +/// gets a second frame and the session stalls behind it. Under X11 (XWayland | |
| 56 | +/// included) presentation does not depend on those callbacks, and the same loop | |
| 57 | +/// runs at full frame rate. Falls back to the default backend when X11 is | |
| 58 | +/// unavailable, so a compositor without XWayland still gets a window. | |
| 59 | +#[cfg(all(unix, not(target_os = "macos"), not(target_os = "android")))] | |
| 60 | +fn build_event_loop() -> Result<EventLoop<()>, String> { | |
| 61 | + use winit::platform::x11::EventLoopBuilderExtX11 as _; | |
| 62 | + | |
| 63 | + if let Ok(el) = EventLoop::builder().with_x11().build() { | |
| 64 | + return Ok(el); | |
| 65 | + } | |
| 66 | + eprintln!("vidya: X11 unavailable; falling back to Wayland (expect stalls)"); | |
| 67 | + EventLoop::builder() | |
| 68 | + .build() | |
| 69 | + .map_err(|e| format!("event loop: {e}")) | |
| 70 | +} | |
| 71 | + | |
| 72 | +/// Android has no display connection to choose: the activity already owns one, | |
| 73 | +/// and winit reaches it through the handle the glue was started with. Without | |
| 74 | +/// that handle there is no event loop to build at all, which is why | |
| 75 | +/// `libvidya.so` is the NativeActivity's own library — see `android.rs`. | |
| 76 | +#[cfg(target_os = "android")] | |
| 77 | +fn build_event_loop() -> Result<EventLoop<()>, String> { | |
| 78 | + use winit::platform::android::EventLoopBuilderExtAndroid as _; | |
| 79 | + | |
| 80 | + let app = crate::android::android_app() | |
| 81 | + .ok_or_else(|| "no AndroidApp: vidya_open ran outside android_main".to_owned())?; | |
| 82 | + EventLoop::builder() | |
| 83 | + .with_android_app(app) | |
| 84 | + .build() | |
| 85 | + .map_err(|e| format!("event loop: {e}")) | |
| 86 | +} | |
| 87 | + | |
| 88 | +#[cfg(not(all(unix, not(target_os = "macos"))))] | |
| 89 | +fn build_event_loop() -> Result<EventLoop<()>, String> { | |
| 90 | + EventLoop::builder() | |
| 91 | + .build() | |
| 92 | + .map_err(|e| format!("event loop: {e}")) | |
| 93 | +} | |
| 94 | + | |
| 95 | +/// Window, GL surface, painter, and egui input translation. | |
| 96 | +/// | |
| 97 | +/// Created on the first `resumed`, the only point where winit guarantees a | |
| 98 | +/// usable display connection on every platform. | |
| 99 | +/// | |
| 100 | +/// The surface is the one part that does not live as long as the rest. Android | |
| 101 | +/// takes the native window away whenever the activity leaves the foreground and | |
| 102 | +/// hands back a *new* one on the way in, so a surface built on the old one | |
| 103 | +/// presents to nothing — a black window, with `swap_buffers` reporting nothing | |
| 104 | +/// wrong. It is dropped on `suspended` and rebuilt on the next `resumed`; the | |
| 105 | +/// context and its textures outlive both, which is why the app comes back with | |
| 106 | +/// its fonts and images intact rather than reloading them. | |
| 107 | +struct Gl { | |
| 108 | + window: Window, | |
| 109 | + /// `None` between `suspended` and the `resumed` that follows it. | |
| 110 | + surface: Option<Surface<WindowSurface>>, | |
| 111 | + /// Kept for that rebuild: a surface has to match the config its context | |
| 112 | + /// was created against. | |
| 113 | + config: Config, | |
| 114 | + context: PossiblyCurrentContext, | |
| 115 | + painter: egui_glow::Painter, | |
| 116 | + winit_state: egui_winit::State, | |
| 117 | +} | |
| 118 | + | |
| 119 | +impl Gl { | |
| 120 | + fn create( | |
| 121 | + el: &ActiveEventLoop, | |
| 122 | + egui_ctx: &egui::Context, | |
| 123 | + title: &str, | |
| 124 | + width: u32, | |
| 125 | + height: u32, | |
| 126 | + ) -> Result<Self, String> { | |
| 127 | + let attrs = Window::default_attributes() | |
| 128 | + .with_title(title) | |
| 129 | + .with_inner_size(LogicalSize::new(width as f64, height as f64)); | |
| 130 | + | |
| 131 | + let (window, config) = DisplayBuilder::new() | |
| 132 | + .with_window_attributes(Some(attrs)) | |
| 133 | + .build( | |
| 134 | + el, | |
| 135 | + ConfigTemplateBuilder::new().with_alpha_size(0), | |
| 136 | + // egui does its own anti-aliasing; this just avoids picking a | |
| 137 | + // degenerate config. | |
| 138 | + |configs| { | |
| 139 | + configs | |
| 140 | + .reduce(|best, c| { | |
| 141 | + if c.num_samples() > best.num_samples() { | |
| 142 | + c | |
| 143 | + } else { | |
| 144 | + best | |
| 145 | + } | |
| 146 | + }) | |
| 147 | + .expect("no GL config") | |
| 148 | + }, | |
| 149 | + ) | |
| 150 | + .map_err(|e| format!("GL display: {e}"))?; | |
| 151 | + let window = window.ok_or_else(|| "no window was created".to_owned())?; | |
| 152 | + | |
| 153 | + let raw = window | |
| 154 | + .window_handle() | |
| 155 | + .map_err(|e| format!("window handle: {e}"))? | |
| 156 | + .as_raw(); | |
| 157 | + let display = config.display(); | |
| 158 | + | |
| 159 | + // Desktop GL first, GLES second — the order eframe uses. | |
| 160 | + let context = unsafe { | |
| 161 | + display | |
| 162 | + .create_context(&config, &ContextAttributesBuilder::new().build(Some(raw))) | |
| 163 | + .or_else(|_| { | |
| 164 | + display.create_context( | |
| 165 | + &config, | |
| 166 | + &ContextAttributesBuilder::new() | |
| 167 | + .with_context_api(ContextApi::Gles(None)) | |
| 168 | + .build(Some(raw)), | |
| 169 | + ) | |
| 170 | + }) | |
| 171 | + .map_err(|e| format!("GL context: {e}"))? | |
| 172 | + }; | |
| 173 | + | |
| 174 | + let surface = build_surface(&window, &config)?; | |
| 175 | + let context = context | |
| 176 | + .make_current(&surface) | |
| 177 | + .map_err(|e| format!("make current: {e}"))?; | |
| 178 | + set_vsync(&surface, &context); | |
| 179 | + | |
| 180 | + let glow_ctx = unsafe { | |
| 181 | + glow::Context::from_loader_function_cstr(|s| display.get_proc_address(s).cast()) | |
| 182 | + }; | |
| 183 | + let painter = egui_glow::Painter::new(Arc::new(glow_ctx), "", None, false) | |
| 184 | + .map_err(|e| format!("painter: {e}"))?; | |
| 185 | + | |
| 186 | + let winit_state = egui_winit::State::new( | |
| 187 | + egui_ctx.clone(), | |
| 188 | + ViewportId::ROOT, | |
| 189 | + &window, | |
| 190 | + None, | |
| 191 | + window.theme(), | |
| 192 | + Some(painter.max_texture_side()), | |
| 193 | + ); | |
| 194 | + | |
| 195 | + Ok(Self { | |
| 196 | + window, | |
| 197 | + surface: Some(surface), | |
| 198 | + config, | |
| 199 | + context, | |
| 200 | + painter, | |
| 201 | + winit_state, | |
| 202 | + }) | |
| 203 | + } | |
| 204 | + | |
| 205 | + /// Give up the surface the platform is about to invalidate. The context is | |
| 206 | + /// released from this thread first: destroying a surface that is still | |
| 207 | + /// current is not something EGL owes an answer for. | |
| 208 | + fn suspend(&mut self) { | |
| 209 | + if self.surface.is_none() { | |
| 210 | + return; | |
| 211 | + } | |
| 212 | + if let Err(e) = self.context.make_not_current_in_place() { | |
| 213 | + eprintln!("vidya: releasing the GL context failed: {e}"); | |
| 214 | + } | |
| 215 | + self.surface = None; | |
| 216 | + } | |
| 217 | + | |
| 218 | + /// Build a surface on whatever native window the platform has now, and make | |
| 219 | + /// the context current on it again. A failure here leaves the surface | |
| 220 | + /// `None`, so the loop keeps running and drops frames rather than painting | |
| 221 | + /// into a surface that is not there. | |
| 222 | + fn resume(&mut self) { | |
| 223 | + if self.surface.is_some() { | |
| 224 | + return; | |
| 225 | + } | |
| 226 | + let surface = match build_surface(&self.window, &self.config) { | |
| 227 | + Ok(surface) => surface, | |
| 228 | + Err(e) => { | |
| 229 | + eprintln!("vidya: rebuilding the GL surface failed: {e}"); | |
| 230 | + return; | |
| 231 | + } | |
| 232 | + }; | |
| 233 | + if let Err(e) = self.context.make_current(&surface) { | |
| 234 | + eprintln!("vidya: make current failed on resume: {e}"); | |
| 235 | + return; | |
| 236 | + } | |
| 237 | + set_vsync(&surface, &self.context); | |
| 238 | + // The new window is rarely the old one's size — a keyboard may have | |
| 239 | + // gone away under it, or the device turned. | |
| 240 | + self.window.request_redraw(); | |
| 241 | + self.surface = Some(surface); | |
| 242 | + } | |
| 243 | +} | |
| 244 | + | |
| 245 | +fn build_surface(window: &Window, config: &Config) -> Result<Surface<WindowSurface>, String> { | |
| 246 | + let attrs = window | |
| 247 | + .build_surface_attributes(SurfaceAttributesBuilder::new()) | |
| 248 | + .map_err(|e| format!("surface attributes: {e}"))?; | |
| 249 | + // SAFETY: the attributes name this window, which outlives the surface. | |
| 250 | + unsafe { | |
| 251 | + config | |
| 252 | + .display() | |
| 253 | + .create_window_surface(config, &attrs) | |
| 254 | + .map_err(|e| format!("GL surface: {e}")) | |
| 255 | + } | |
| 256 | +} | |
| 257 | + | |
| 258 | +fn set_vsync(surface: &Surface<WindowSurface>, context: &PossiblyCurrentContext) { | |
| 259 | + if let Err(e) = surface.set_swap_interval( | |
| 260 | + context, | |
| 261 | + SwapInterval::Wait(std::num::NonZeroU32::new(1).expect("nonzero")), | |
| 262 | + ) { | |
| 263 | + eprintln!("vidya: vsync unavailable ({e})"); | |
| 264 | + } | |
| 265 | +} | |
| 266 | + | |
| 267 | +/// A tessellated frame waiting for the compositor to ask for it. | |
| 268 | +struct PaintJob { | |
| 269 | + primitives: Vec<egui::ClippedPrimitive>, | |
| 270 | + textures_delta: egui::TexturesDelta, | |
| 271 | + pixels_per_point: f32, | |
| 272 | + clear: [f32; 4], | |
| 273 | +} | |
| 274 | + | |
| 275 | +/// winit event sink. Owns everything that survives between frames. | |
| 276 | +struct Handler { | |
| 277 | + egui_ctx: egui::Context, | |
| 278 | + theme: Theme, | |
| 279 | + gl: Option<Gl>, | |
| 280 | + title: String, | |
| 281 | + width: u32, | |
| 282 | + height: u32, | |
| 283 | + should_close: bool, | |
| 284 | + /// Set when window creation fails, so `vidya_open` can report it. | |
| 285 | + error: Option<String>, | |
| 286 | + /// Whether the compositor is ready for another buffer. Starts open: the | |
| 287 | + /// initial configure entitles us to the first frame, and the frame callback | |
| 288 | + /// that arms every later one is only requested *by* presenting — waiting | |
| 289 | + /// for it before the first present deadlocks. | |
| 290 | + may_present: bool, | |
| 291 | + frames: u32, | |
| 292 | + /// `VIDYA_CAPTURE=<path>`: dump one painted frame, then stop. | |
| 293 | + capture: Option<String>, | |
| 294 | + /// `VIDYA_RESIZE_AT` parsed: (frame, width, height), in frame order. | |
| 295 | + resize_at: Vec<(u32, f64, f64)>, | |
| 296 | + /// Which frame to dump. `VIDYA_CAPTURE_AT` moves it later than the third, | |
| 297 | + /// for a screen that only exists once something has loaded. | |
| 298 | + capture_at: u32, | |
| 299 | +} | |
| 300 | + | |
| 301 | +impl Handler { | |
| 302 | + /// Paint and present. Only ever called from inside `RedrawRequested`. | |
| 303 | + fn present(&mut self, job: PaintJob) { | |
| 304 | + let Some(gl) = self.gl.as_mut() else { | |
| 305 | + return; | |
| 306 | + }; | |
| 307 | + // Between a suspend and the resume after it there is nothing to present | |
| 308 | + // to. The caller's loop is unaffected; it just paints no frames. | |
| 309 | + let Some(surface) = gl.surface.as_ref() else { | |
| 310 | + return; | |
| 311 | + }; | |
| 312 | + let size = gl.window.inner_size(); | |
| 313 | + let dims = [size.width.max(1), size.height.max(1)]; | |
| 314 | + | |
| 315 | + gl.painter.clear(dims, job.clear); | |
| 316 | + gl.painter.paint_and_update_textures( | |
| 317 | + dims, | |
| 318 | + job.pixels_per_point, | |
| 319 | + &job.primitives, | |
| 320 | + &job.textures_delta, | |
| 321 | + ); | |
| 322 | + | |
| 323 | + self.frames += 1; | |
| 324 | + // A resize the compositor never sent, for testing a layout that only | |
| 325 | + // goes wrong when the window changes size under it. `VIDYA_RESIZE_AT` | |
| 326 | + // is `frame:WIDTHxHEIGHT`, and asks winit for the size the way a drag | |
| 327 | + // of the window's edge would. | |
| 328 | + for &(at, w, h) in &self.resize_at { | |
| 329 | + if self.frames == at { | |
| 330 | + let _ = gl | |
| 331 | + .window | |
| 332 | + .request_inner_size(winit::dpi::LogicalSize::new(w, h)); | |
| 333 | + } | |
| 334 | + } | |
| 335 | + // Third frame: fonts and layout have settled by then. | |
| 336 | + if self.frames == self.capture_at { | |
| 337 | + if let Some(path) = self.capture.take() { | |
| 338 | + capture_frame(gl, dims, &path); | |
| 339 | + } | |
| 340 | + } | |
| 341 | + | |
| 342 | + // Order matters. `pre_present_notify` lets winit attach its frame | |
| 343 | + // callback to the commit that `swap_buffers` is about to make, and the | |
| 344 | + // redraw request that arms the *next* callback only counts once that | |
| 345 | + // commit has happened. | |
| 346 | + gl.window.pre_present_notify(); | |
| 347 | + if let Err(e) = surface.swap_buffers(&gl.context) { | |
| 348 | + eprintln!("vidya: swap_buffers failed: {e}"); | |
| 349 | + } | |
| 350 | + gl.window.request_redraw(); | |
| 351 | + } | |
| 352 | +} | |
| 353 | + | |
| 354 | +/// Put a Ctrl/Cmd+V back into egui's input when it dropped the keystroke. | |
| 355 | +/// | |
| 356 | +/// egui-winit answers a paste shortcut by reading the clipboard's *text* and | |
| 357 | +/// pushing an `Event::Paste` with it — and returns there, pushing nothing at | |
| 358 | +/// all when the clipboard holds no text. A copied picture is exactly that | |
| 359 | +/// case, so the one gesture that means "paste this picture" was the one | |
| 360 | +/// gesture egui never heard about. | |
| 361 | +/// | |
| 362 | +/// The key event it would have pushed goes back in. Nothing in egui acts on a | |
| 363 | +/// bare Ctrl+V — a text field pastes from `Event::Paste` — so this is inert | |
| 364 | +/// except to a caller that goes looking for it, which is what `:entry`'s | |
| 365 | +/// `paste-empty` does. | |
| 366 | +fn note_paste_shortcut(gl: &mut Gl, event: &WindowEvent) { | |
| 367 | + let WindowEvent::KeyboardInput { event: key, .. } = event else { | |
| 368 | + return; | |
| 369 | + }; | |
| 370 | + if !key.state.is_pressed() { | |
| 371 | + return; | |
| 372 | + } | |
| 373 | + let modifiers = gl.winit_state.egui_input().modifiers; | |
| 374 | + if !modifiers.command { | |
| 375 | + return; | |
| 376 | + } | |
| 377 | + // Logical first, physical as the fallback: the same rule egui-winit uses, | |
| 378 | + // so a layout with no Latin V of its own still pastes from where V sits. | |
| 379 | + let logical_v = matches!( | |
| 380 | + &key.logical_key, | |
| 381 | + winit::keyboard::Key::Character(c) if c.eq_ignore_ascii_case("v") | |
| 382 | + ); | |
| 383 | + let physical_v = matches!( | |
| 384 | + key.physical_key, | |
| 385 | + winit::keyboard::PhysicalKey::Code(winit::keyboard::KeyCode::KeyV) | |
| 386 | + ); | |
| 387 | + if !logical_v && !physical_v { | |
| 388 | + return; | |
| 389 | + } | |
| 390 | + gl.winit_state | |
| 391 | + .egui_input_mut() | |
| 392 | + .events | |
| 393 | + .push(egui::Event::Key { | |
| 394 | + key: egui::Key::V, | |
| 395 | + physical_key: None, | |
| 396 | + pressed: true, | |
| 397 | + repeat: false, | |
| 398 | + modifiers, | |
| 399 | + }); | |
| 400 | +} | |
| 401 | + | |
| 402 | +impl ApplicationHandler for Handler { | |
| 403 | + fn resumed(&mut self, el: &ActiveEventLoop) { | |
| 404 | + if let Some(gl) = self.gl.as_mut() { | |
| 405 | + // Not a first start: the activity came back to the foreground, and | |
| 406 | + // what it lost while it was away was the surface. | |
| 407 | + gl.resume(); | |
| 408 | + // A window that stopped being composited stopped presenting too, | |
| 409 | + // and the redraw request that arms the next frame callback is only | |
| 410 | + // made *by* presenting. Nothing would ever ask for the first frame | |
| 411 | + // back without this. | |
| 412 | + self.may_present = true; | |
| 413 | + return; | |
| 414 | + } | |
| 415 | + match Gl::create(el, &self.egui_ctx, &self.title, self.width, self.height) { | |
| 416 | + Ok(gl) => { | |
| 417 | + vidya_core::apply(&self.egui_ctx, &self.theme); | |
| 418 | + self.gl = Some(gl); | |
| 419 | + } | |
| 420 | + Err(e) => { | |
| 421 | + self.error = Some(e); | |
| 422 | + self.should_close = true; | |
| 423 | + } | |
| 424 | + } | |
| 425 | + } | |
| 426 | + | |
| 427 | + /// The platform is taking the native window away — Android does this every | |
| 428 | + /// time the activity leaves the foreground. | |
| 429 | + fn suspended(&mut self, _el: &ActiveEventLoop) { | |
| 430 | + if let Some(gl) = self.gl.as_mut() { | |
| 431 | + gl.suspend(); | |
| 432 | + } | |
| 433 | + } | |
| 434 | + | |
| 435 | + fn window_event(&mut self, _el: &ActiveEventLoop, _id: WindowId, event: WindowEvent) { | |
| 436 | + if let Some(gl) = self.gl.as_mut() { | |
| 437 | + // egui sees every event, including the ones handled below. | |
| 438 | + let _ = gl.winit_state.on_window_event(&gl.window, &event); | |
| 439 | + note_paste_shortcut(gl, &event); | |
| 440 | + | |
| 441 | + if let WindowEvent::Resized(size) = event { | |
| 442 | + if size.width > 0 && size.height > 0 { | |
| 443 | + if let Some(surface) = gl.surface.as_ref() { | |
| 444 | + gl.window.resize_surface(surface, &gl.context); | |
| 445 | + } | |
| 446 | + } | |
| 447 | + } | |
| 448 | + } | |
| 449 | + | |
| 450 | + match event { | |
| 451 | + WindowEvent::CloseRequested | WindowEvent::Destroyed => self.should_close = true, | |
| 452 | + WindowEvent::RedrawRequested => self.may_present = true, | |
| 453 | + _ => {} | |
| 454 | + } | |
| 455 | + } | |
| 456 | +} | |
| 457 | + | |
| 458 | +/// One UI context per process, matching the ABI's window model. | |
| 459 | +pub struct App { | |
| 460 | + event_loop: EventLoop<()>, | |
| 461 | + handler: Handler, | |
| 462 | + /// Live only between `begin_frame` and `end_frame`. | |
| 463 | + pub stack: Stack, | |
| 464 | + frame_budget: Duration, | |
| 465 | + frame_started: Instant, | |
| 466 | + /// Frames skipped because nothing was compositing the window. | |
| 467 | + dropped: u32, | |
| 468 | + font_generation: u32, | |
| 469 | +} | |
| 470 | + | |
| 471 | +impl App { | |
| 472 | + pub fn open(width: i32, height: i32, title: &str) -> Result<Self, String> { | |
| 473 | + let event_loop = build_event_loop()?; | |
| 474 | + | |
| 475 | + let mut app = Self { | |
| 476 | + event_loop, | |
| 477 | + handler: Handler { | |
| 478 | + egui_ctx: egui::Context::default(), | |
| 479 | + theme: Theme::dark(), | |
| 480 | + gl: None, | |
| 481 | + title: title.to_owned(), | |
| 482 | + width: width.max(1) as u32, | |
| 483 | + height: height.max(1) as u32, | |
| 484 | + should_close: false, | |
| 485 | + error: None, | |
| 486 | + may_present: true, | |
| 487 | + frames: 0, | |
| 488 | + capture: std::env::var("VIDYA_CAPTURE").ok(), | |
| 489 | + resize_at: std::env::var("VIDYA_RESIZE_AT") | |
| 490 | + .unwrap_or_default() | |
| 491 | + .split(',') | |
| 492 | + .filter_map(|step| { | |
| 493 | + let (at, size) = step.split_once(':')?; | |
| 494 | + let (w, h) = size.split_once('x')?; | |
| 495 | + Some((at.parse().ok()?, w.parse().ok()?, h.parse().ok()?)) | |
| 496 | + }) | |
| 497 | + .collect(), | |
| 498 | + capture_at: std::env::var("VIDYA_CAPTURE_AT") | |
| 499 | + .ok() | |
| 500 | + .and_then(|v| v.parse().ok()) | |
| 501 | + .unwrap_or(3), | |
| 502 | + }, | |
| 503 | + stack: Stack::default(), | |
| 504 | + frame_budget: DEFAULT_FRAME_BUDGET, | |
| 505 | + frame_started: Instant::now(), | |
| 506 | + dropped: 0, | |
| 507 | + font_generation: 0, | |
| 508 | + }; | |
| 509 | + | |
| 510 | + // Pump until the platform resumes us and the window exists. | |
| 511 | + let deadline = Instant::now() + OPEN_TIMEOUT; | |
| 512 | + while app.handler.gl.is_none() && app.handler.error.is_none() { | |
| 513 | + if Instant::now() > deadline { | |
| 514 | + return Err("timed out waiting for a window".to_owned()); | |
| 515 | + } | |
| 516 | + app.pump(Duration::from_millis(10)); | |
| 517 | + } | |
| 518 | + match app.handler.error.take() { | |
| 519 | + Some(e) => Err(e), | |
| 520 | + None => Ok(app), | |
| 521 | + } | |
| 522 | + } | |
| 523 | + | |
| 524 | + fn pump(&mut self, timeout: Duration) { | |
| 525 | + self.event_loop | |
| 526 | + .pump_app_events(Some(timeout), &mut self.handler); | |
| 527 | + } | |
| 528 | + | |
| 529 | + pub fn should_close(&mut self) -> bool { | |
| 530 | + self.pump(Duration::ZERO); | |
| 531 | + self.handler.should_close | |
| 532 | + } | |
| 533 | + | |
| 534 | + pub fn theme(&self) -> &Theme { | |
| 535 | + &self.handler.theme | |
| 536 | + } | |
| 537 | + | |
| 538 | + /// The innermost open node plus the live theme. | |
| 539 | + /// | |
| 540 | + /// Returned together because widget calls need both, and they live in | |
| 541 | + /// different fields — splitting the borrow here keeps the call sites plain. | |
| 542 | + pub fn ui(&mut self) -> Option<(&mut egui::Ui, &Theme)> { | |
| 543 | + let theme = &self.handler.theme; | |
| 544 | + self.stack.top().map(|ui| (ui, theme)) | |
| 545 | + } | |
| 546 | + | |
| 547 | + pub fn set_mode(&mut self, mode: vidya_core::Mode) { | |
| 548 | + self.handler.theme = match mode { | |
| 549 | + vidya_core::Mode::Dark => Theme::dark(), | |
| 550 | + vidya_core::Mode::Light => Theme::light(), | |
| 551 | + }; | |
| 552 | + vidya_core::apply(&self.handler.egui_ctx, &self.handler.theme); | |
| 553 | + } | |
| 554 | + | |
| 555 | + pub fn set_target_fps(&mut self, fps: i32) { | |
| 556 | + self.frame_budget = match fps { | |
| 557 | + f if f > 0 => Duration::from_secs_f64(1.0 / f as f64), | |
| 558 | + _ => DEFAULT_FRAME_BUDGET, | |
| 559 | + }; | |
| 560 | + } | |
| 561 | + | |
| 562 | + /// Install a UI font as the highest-priority proportional family. | |
| 563 | + /// | |
| 564 | + /// The symbol fallback installed by [`vidya_core::apply`] stays in place, | |
| 565 | + /// so punctuation and block art keep rendering. | |
| 566 | + pub fn load_font(&mut self, path: &str) -> bool { | |
| 567 | + let Ok(bytes) = std::fs::read(path) else { | |
| 568 | + return false; | |
| 569 | + }; | |
| 570 | + self.font_generation += 1; | |
| 571 | + // Unique name per load: egui skips a name it already has. | |
| 572 | + let name = format!("vidya-ui-{}", self.font_generation); | |
| 573 | + self.handler | |
| 574 | + .egui_ctx | |
| 575 | + .add_font(egui::epaint::text::FontInsert::new( | |
| 576 | + &name, | |
| 577 | + egui::FontData::from_owned(bytes), | |
| 578 | + vec![egui::epaint::text::InsertFontFamily { | |
| 579 | + family: egui::FontFamily::Proportional, | |
| 580 | + priority: egui::epaint::text::FontPriority::Highest, | |
| 581 | + }], | |
| 582 | + )); | |
| 583 | + true | |
| 584 | + } | |
| 585 | + | |
| 586 | + /// Drain pending input and open an egui pass with a root [`egui::Ui`]. | |
| 587 | + pub fn begin_frame(&mut self) { | |
| 588 | + self.pump(Duration::ZERO); | |
| 589 | + self.frame_started = Instant::now(); | |
| 590 | + | |
| 591 | + if self.handler.gl.is_none() || self.stack.is_active() { | |
| 592 | + // No window, or the caller skipped `vidya_end_frame`. | |
| 593 | + return; | |
| 594 | + } | |
| 595 | + let Some(gl) = self.handler.gl.as_mut() else { | |
| 596 | + return; | |
| 597 | + }; | |
| 598 | + | |
| 599 | + let input = gl.winit_state.take_egui_input(&gl.window); | |
| 600 | + let ctx = &self.handler.egui_ctx; | |
| 601 | + ctx.begin_pass(input); | |
| 602 | + self.stack.push_root(ctx); | |
| 603 | + } | |
| 604 | + | |
| 605 | + /// Close the pass, then hand the frame to the event loop to present. | |
| 606 | + pub fn end_frame(&mut self) { | |
| 607 | + if !self.stack.is_active() { | |
| 608 | + return; | |
| 609 | + } | |
| 610 | + // Close anything the caller left open (a missing `vidya_card_end`). | |
| 611 | + self.stack.unwind(); | |
| 612 | + if self.handler.gl.is_none() { | |
| 613 | + return; | |
| 614 | + } | |
| 615 | + | |
| 616 | + let egui::FullOutput { | |
| 617 | + platform_output, | |
| 618 | + textures_delta, | |
| 619 | + shapes, | |
| 620 | + pixels_per_point, | |
| 621 | + .. | |
| 622 | + } = self.handler.egui_ctx.end_pass(); | |
| 623 | + let primitives = self.handler.egui_ctx.tessellate(shapes, pixels_per_point); | |
| 624 | + // Gamma, not linear: `Painter::clear` hands these straight to | |
| 625 | + // `glClearColor` against an sRGB framebuffer, so a `Rgba::from` | |
| 626 | + // conversion here would be applied twice and the window would clear to | |
| 627 | + // near-black instead of the palette's charcoal. | |
| 628 | + let clear = self | |
| 629 | + .handler | |
| 630 | + .theme | |
| 631 | + .palette | |
| 632 | + .window_bg | |
| 633 | + .to_normalized_gamma_f32(); | |
| 634 | + | |
| 635 | + if let Some(gl) = self.handler.gl.as_mut() { | |
| 636 | + gl.winit_state | |
| 637 | + .handle_platform_output(&gl.window, platform_output); | |
| 638 | + } | |
| 639 | + | |
| 640 | + // Wait for the compositor to want a frame, dispatching events while we | |
| 641 | + // wait — never inside `swap_buffers`, which would park the thread that | |
| 642 | + // owes the compositor its replies and stall the session. This wait is | |
| 643 | + // also what paces the caller's loop to the display. | |
| 644 | + let deadline = Instant::now() + PRESENT_TIMEOUT; | |
| 645 | + while !self.handler.may_present && Instant::now() < deadline { | |
| 646 | + self.pump(Duration::from_millis(2)); | |
| 647 | + } | |
| 648 | + | |
| 649 | + if self.handler.may_present { | |
| 650 | + self.handler.may_present = false; | |
| 651 | + self.handler.present(PaintJob { | |
| 652 | + primitives, | |
| 653 | + textures_delta, | |
| 654 | + pixels_per_point, | |
| 655 | + clear, | |
| 656 | + }); | |
| 657 | + } else { | |
| 658 | + // Nobody is compositing this window (minimized, another workspace). | |
| 659 | + // Drop the frame rather than force a present that would block. | |
| 660 | + self.dropped += 1; | |
| 661 | + if self.dropped == 1 { | |
| 662 | + eprintln!("vidya: window is not being composited; dropping frames"); | |
| 663 | + } | |
| 664 | + } | |
| 665 | + | |
| 666 | + // Floor for platforms that do not throttle presents at all. | |
| 667 | + if let Some(left) = self.frame_budget.checked_sub(self.frame_started.elapsed()) { | |
| 668 | + std::thread::sleep(left); | |
| 669 | + } | |
| 670 | + } | |
| 671 | +} | |
| 672 | + | |
| 673 | +impl Drop for App { | |
| 674 | + fn drop(&mut self) { | |
| 675 | + self.stack.unwind(); | |
| 676 | + if let Some(gl) = self.handler.gl.as_mut() { | |
| 677 | + gl.painter.destroy(); | |
| 678 | + } | |
| 679 | + } | |
| 680 | +} | |
| 681 | + | |
| 682 | +/// Write the painted framebuffer to a binary PPM. | |
| 683 | +/// | |
| 684 | +/// A rendering backend is otherwise unverifiable where the compositor refuses | |
| 685 | +/// screenshots, and in CI where there is nobody to look. Off unless | |
| 686 | +/// `VIDYA_CAPTURE` names a path. | |
| 687 | +fn capture_frame(gl: &Gl, dims: [u32; 2], path: &str) { | |
| 688 | + use glow::HasContext as _; | |
| 689 | + use std::io::Write as _; | |
| 690 | + | |
| 691 | + let [w, h] = dims; | |
| 692 | + let mut rgba = vec![0u8; (w as usize) * (h as usize) * 4]; | |
| 693 | + unsafe { | |
| 694 | + // Drain the pipeline: the pixels need not exist yet. | |
| 695 | + gl.painter.gl().finish(); | |
| 696 | + gl.painter.gl().read_pixels( | |
| 697 | + 0, | |
| 698 | + 0, | |
| 699 | + w as i32, | |
| 700 | + h as i32, | |
| 701 | + glow::RGBA, | |
| 702 | + glow::UNSIGNED_BYTE, | |
| 703 | + glow::PixelPackData::Slice(Some(&mut rgba)), | |
| 704 | + ); | |
| 705 | + } | |
| 706 | + | |
| 707 | + let mut out = Vec::with_capacity((w as usize) * (h as usize) * 3 + 32); | |
| 708 | + out.extend_from_slice(format!("P6\n{w} {h}\n255\n").as_bytes()); | |
| 709 | + // GL origin is bottom-left; PPM is top-down. | |
| 710 | + for row in (0..h as usize).rev() { | |
| 711 | + let start = row * w as usize * 4; | |
| 712 | + for px in rgba[start..start + w as usize * 4].chunks_exact(4) { | |
| 713 | + out.extend_from_slice(&px[..3]); | |
| 714 | + } | |
| 715 | + } | |
| 716 | + | |
| 717 | + match std::fs::File::create(path).and_then(|mut f| f.write_all(&out)) { | |
| 718 | + Ok(()) => eprintln!("vidya: wrote {path}"), | |
| 719 | + Err(e) => eprintln!("vidya: capture failed: {e}"), | |
| 720 | + } | |
| 721 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,721 @@ | |||
| 1 | +//! Window, GL context, and the caller-driven frame loop. | ||
| 2 | +//! | ||
| 3 | +//! The C ABI is pull-style: the caller owns the loop and calls | ||
| 4 | +//! [`App::begin_frame`] / [`App::end_frame`] around its own widget calls. | ||
| 5 | +//! `eframe` inverts that — it owns the loop and calls the app — so this backend | ||
| 6 | +//! drives `winit` with `pump_app_events` and paints through `egui_glow`, | ||
| 7 | +//! keeping the ABI (and every consumer of it) unchanged. | ||
| 8 | +//! | ||
| 9 | +//! One thing does **not** get to move outside the event loop: the present. | ||
| 10 | +//! Wayland gives a surface one buffer per frame callback, and winit tracks | ||
| 11 | +//! those callbacks itself. Presenting from outside its `RedrawRequested` | ||
| 12 | +//! dispatch desynchronizes that bookkeeping — the compositor stops calling | ||
| 13 | +//! back, the next `swap_buffers` blocks inside EGL with the session waiting on | ||
| 14 | +//! it, and the whole desktop stalls for seconds. So [`App::end_frame`] hands | ||
| 15 | +//! the finished frame to [`Handler`] and pumps until it is painted from inside | ||
| 16 | +//! the callback, which is also what paces the caller's loop. | ||
| 17 | + | ||
| 18 | +use std::sync::Arc; | ||
| 19 | +use std::time::{Duration, Instant}; | ||
| 20 | + | ||
| 21 | +use egui::ViewportId; | ||
| 22 | +use glutin::config::{Config, ConfigTemplateBuilder}; | ||
| 23 | +use glutin::context::{ContextApi, ContextAttributesBuilder, PossiblyCurrentContext}; | ||
| 24 | +use glutin::display::GetGlDisplay as _; | ||
| 25 | +use glutin::prelude::*; | ||
| 26 | +use glutin::surface::{Surface, SurfaceAttributesBuilder, SwapInterval, WindowSurface}; | ||
| 27 | +use glutin_winit::{DisplayBuilder, GlWindow as _}; | ||
| 28 | +use vidya_core::Theme; | ||
| 29 | +use winit::application::ApplicationHandler; | ||
| 30 | +use winit::dpi::LogicalSize; | ||
| 31 | +use winit::event::WindowEvent; | ||
| 32 | +use winit::event_loop::{ActiveEventLoop, EventLoop}; | ||
| 33 | +use winit::platform::pump_events::EventLoopExtPumpEvents as _; | ||
| 34 | +use winit::raw_window_handle::HasWindowHandle as _; | ||
| 35 | +use winit::window::{Window, WindowId}; | ||
| 36 | + | ||
| 37 | +use crate::ui::Stack; | ||
| 38 | + | ||
| 39 | +/// How long `vidya_open` waits for the platform to hand us a window. | ||
| 40 | +const OPEN_TIMEOUT: Duration = Duration::from_secs(5); | ||
| 41 | + | ||
| 42 | +/// How long a finished frame waits for a frame callback before being dropped. | ||
| 43 | +/// A window nobody is compositing (minimized, on another workspace) simply | ||
| 44 | +/// stops presenting; the caller's loop keeps running. | ||
| 45 | +const PRESENT_TIMEOUT: Duration = Duration::from_millis(250); | ||
| 46 | + | ||
| 47 | +/// Pacing floor, so a caller that never sets a target FPS still yields. | ||
| 48 | +const DEFAULT_FRAME_BUDGET: Duration = Duration::from_micros(16_666); | ||
| 49 | + | ||
| 50 | +/// Build the event loop, preferring X11 where both backends exist. | ||
| 51 | +/// | ||
| 52 | +/// Native Wayland does not survive this ABI's shape: the caller owns the loop, | ||
| 53 | +/// so winit is driven with `pump_app_events`, and a surface driven that way | ||
| 54 | +/// stops receiving frame callbacks after its first commit — the window never | ||
| 55 | +/// gets a second frame and the session stalls behind it. Under X11 (XWayland | ||
| 56 | +/// included) presentation does not depend on those callbacks, and the same loop | ||
| 57 | +/// runs at full frame rate. Falls back to the default backend when X11 is | ||
| 58 | +/// unavailable, so a compositor without XWayland still gets a window. | ||
| 59 | +#[cfg(all(unix, not(target_os = "macos"), not(target_os = "android")))] | ||
| 60 | +fn build_event_loop() -> Result<EventLoop<()>, String> { | ||
| 61 | + use winit::platform::x11::EventLoopBuilderExtX11 as _; | ||
| 62 | + | ||
| 63 | + if let Ok(el) = EventLoop::builder().with_x11().build() { | ||
| 64 | + return Ok(el); | ||
| 65 | + } | ||
| 66 | + eprintln!("vidya: X11 unavailable; falling back to Wayland (expect stalls)"); | ||
| 67 | + EventLoop::builder() | ||
| 68 | + .build() | ||
| 69 | + .map_err(|e| format!("event loop: {e}")) | ||
| 70 | +} | ||
| 71 | + | ||
| 72 | +/// Android has no display connection to choose: the activity already owns one, | ||
| 73 | +/// and winit reaches it through the handle the glue was started with. Without | ||
| 74 | +/// that handle there is no event loop to build at all, which is why | ||
| 75 | +/// `libvidya.so` is the NativeActivity's own library — see `android.rs`. | ||
| 76 | +#[cfg(target_os = "android")] | ||
| 77 | +fn build_event_loop() -> Result<EventLoop<()>, String> { | ||
| 78 | + use winit::platform::android::EventLoopBuilderExtAndroid as _; | ||
| 79 | + | ||
| 80 | + let app = crate::android::android_app() | ||
| 81 | + .ok_or_else(|| "no AndroidApp: vidya_open ran outside android_main".to_owned())?; | ||
| 82 | + EventLoop::builder() | ||
| 83 | + .with_android_app(app) | ||
| 84 | + .build() | ||
| 85 | + .map_err(|e| format!("event loop: {e}")) | ||
| 86 | +} | ||
| 87 | + | ||
| 88 | +#[cfg(not(all(unix, not(target_os = "macos"))))] | ||
| 89 | +fn build_event_loop() -> Result<EventLoop<()>, String> { | ||
| 90 | + EventLoop::builder() | ||
| 91 | + .build() | ||
| 92 | + .map_err(|e| format!("event loop: {e}")) | ||
| 93 | +} | ||
| 94 | + | ||
| 95 | +/// Window, GL surface, painter, and egui input translation. | ||
| 96 | +/// | ||
| 97 | +/// Created on the first `resumed`, the only point where winit guarantees a | ||
| 98 | +/// usable display connection on every platform. | ||
| 99 | +/// | ||
| 100 | +/// The surface is the one part that does not live as long as the rest. Android | ||
| 101 | +/// takes the native window away whenever the activity leaves the foreground and | ||
| 102 | +/// hands back a *new* one on the way in, so a surface built on the old one | ||
| 103 | +/// presents to nothing — a black window, with `swap_buffers` reporting nothing | ||
| 104 | +/// wrong. It is dropped on `suspended` and rebuilt on the next `resumed`; the | ||
| 105 | +/// context and its textures outlive both, which is why the app comes back with | ||
| 106 | +/// its fonts and images intact rather than reloading them. | ||
| 107 | +struct Gl { | ||
| 108 | + window: Window, | ||
| 109 | + /// `None` between `suspended` and the `resumed` that follows it. | ||
| 110 | + surface: Option<Surface<WindowSurface>>, | ||
| 111 | + /// Kept for that rebuild: a surface has to match the config its context | ||
| 112 | + /// was created against. | ||
| 113 | + config: Config, | ||
| 114 | + context: PossiblyCurrentContext, | ||
| 115 | + painter: egui_glow::Painter, | ||
| 116 | + winit_state: egui_winit::State, | ||
| 117 | +} | ||
| 118 | + | ||
| 119 | +impl Gl { | ||
| 120 | + fn create( | ||
| 121 | + el: &ActiveEventLoop, | ||
| 122 | + egui_ctx: &egui::Context, | ||
| 123 | + title: &str, | ||
| 124 | + width: u32, | ||
| 125 | + height: u32, | ||
| 126 | + ) -> Result<Self, String> { | ||
| 127 | + let attrs = Window::default_attributes() | ||
| 128 | + .with_title(title) | ||
| 129 | + .with_inner_size(LogicalSize::new(width as f64, height as f64)); | ||
| 130 | + | ||
| 131 | + let (window, config) = DisplayBuilder::new() | ||
| 132 | + .with_window_attributes(Some(attrs)) | ||
| 133 | + .build( | ||
| 134 | + el, | ||
| 135 | + ConfigTemplateBuilder::new().with_alpha_size(0), | ||
| 136 | + // egui does its own anti-aliasing; this just avoids picking a | ||
| 137 | + // degenerate config. | ||
| 138 | + |configs| { | ||
| 139 | + configs | ||
| 140 | + .reduce(|best, c| { | ||
| 141 | + if c.num_samples() > best.num_samples() { | ||
| 142 | + c | ||
| 143 | + } else { | ||
| 144 | + best | ||
| 145 | + } | ||
| 146 | + }) | ||
| 147 | + .expect("no GL config") | ||
| 148 | + }, | ||
| 149 | + ) | ||
| 150 | + .map_err(|e| format!("GL display: {e}"))?; | ||
| 151 | + let window = window.ok_or_else(|| "no window was created".to_owned())?; | ||
| 152 | + | ||
| 153 | + let raw = window | ||
| 154 | + .window_handle() | ||
| 155 | + .map_err(|e| format!("window handle: {e}"))? | ||
| 156 | + .as_raw(); | ||
| 157 | + let display = config.display(); | ||
| 158 | + | ||
| 159 | + // Desktop GL first, GLES second — the order eframe uses. | ||
| 160 | + let context = unsafe { | ||
| 161 | + display | ||
| 162 | + .create_context(&config, &ContextAttributesBuilder::new().build(Some(raw))) | ||
| 163 | + .or_else(|_| { | ||
| 164 | + display.create_context( | ||
| 165 | + &config, | ||
| 166 | + &ContextAttributesBuilder::new() | ||
| 167 | + .with_context_api(ContextApi::Gles(None)) | ||
| 168 | + .build(Some(raw)), | ||
| 169 | + ) | ||
| 170 | + }) | ||
| 171 | + .map_err(|e| format!("GL context: {e}"))? | ||
| 172 | + }; | ||
| 173 | + | ||
| 174 | + let surface = build_surface(&window, &config)?; | ||
| 175 | + let context = context | ||
| 176 | + .make_current(&surface) | ||
| 177 | + .map_err(|e| format!("make current: {e}"))?; | ||
| 178 | + set_vsync(&surface, &context); | ||
| 179 | + | ||
| 180 | + let glow_ctx = unsafe { | ||
| 181 | + glow::Context::from_loader_function_cstr(|s| display.get_proc_address(s).cast()) | ||
| 182 | + }; | ||
| 183 | + let painter = egui_glow::Painter::new(Arc::new(glow_ctx), "", None, false) | ||
| 184 | + .map_err(|e| format!("painter: {e}"))?; | ||
| 185 | + | ||
| 186 | + let winit_state = egui_winit::State::new( | ||
| 187 | + egui_ctx.clone(), | ||
| 188 | + ViewportId::ROOT, | ||
| 189 | + &window, | ||
| 190 | + None, | ||
| 191 | + window.theme(), | ||
| 192 | + Some(painter.max_texture_side()), | ||
| 193 | + ); | ||
| 194 | + | ||
| 195 | + Ok(Self { | ||
| 196 | + window, | ||
| 197 | + surface: Some(surface), | ||
| 198 | + config, | ||
| 199 | + context, | ||
| 200 | + painter, | ||
| 201 | + winit_state, | ||
| 202 | + }) | ||
| 203 | + } | ||
| 204 | + | ||
| 205 | + /// Give up the surface the platform is about to invalidate. The context is | ||
| 206 | + /// released from this thread first: destroying a surface that is still | ||
| 207 | + /// current is not something EGL owes an answer for. | ||
| 208 | + fn suspend(&mut self) { | ||
| 209 | + if self.surface.is_none() { | ||
| 210 | + return; | ||
| 211 | + } | ||
| 212 | + if let Err(e) = self.context.make_not_current_in_place() { | ||
| 213 | + eprintln!("vidya: releasing the GL context failed: {e}"); | ||
| 214 | + } | ||
| 215 | + self.surface = None; | ||
| 216 | + } | ||
| 217 | + | ||
| 218 | + /// Build a surface on whatever native window the platform has now, and make | ||
| 219 | + /// the context current on it again. A failure here leaves the surface | ||
| 220 | + /// `None`, so the loop keeps running and drops frames rather than painting | ||
| 221 | + /// into a surface that is not there. | ||
| 222 | + fn resume(&mut self) { | ||
| 223 | + if self.surface.is_some() { | ||
| 224 | + return; | ||
| 225 | + } | ||
| 226 | + let surface = match build_surface(&self.window, &self.config) { | ||
| 227 | + Ok(surface) => surface, | ||
| 228 | + Err(e) => { | ||
| 229 | + eprintln!("vidya: rebuilding the GL surface failed: {e}"); | ||
| 230 | + return; | ||
| 231 | + } | ||
| 232 | + }; | ||
| 233 | + if let Err(e) = self.context.make_current(&surface) { | ||
| 234 | + eprintln!("vidya: make current failed on resume: {e}"); | ||
| 235 | + return; | ||
| 236 | + } | ||
| 237 | + set_vsync(&surface, &self.context); | ||
| 238 | + // The new window is rarely the old one's size — a keyboard may have | ||
| 239 | + // gone away under it, or the device turned. | ||
| 240 | + self.window.request_redraw(); | ||
| 241 | + self.surface = Some(surface); | ||
| 242 | + } | ||
| 243 | +} | ||
| 244 | + | ||
| 245 | +fn build_surface(window: &Window, config: &Config) -> Result<Surface<WindowSurface>, String> { | ||
| 246 | + let attrs = window | ||
| 247 | + .build_surface_attributes(SurfaceAttributesBuilder::new()) | ||
| 248 | + .map_err(|e| format!("surface attributes: {e}"))?; | ||
| 249 | + // SAFETY: the attributes name this window, which outlives the surface. | ||
| 250 | + unsafe { | ||
| 251 | + config | ||
| 252 | + .display() | ||
| 253 | + .create_window_surface(config, &attrs) | ||
| 254 | + .map_err(|e| format!("GL surface: {e}")) | ||
| 255 | + } | ||
| 256 | +} | ||
| 257 | + | ||
| 258 | +fn set_vsync(surface: &Surface<WindowSurface>, context: &PossiblyCurrentContext) { | ||
| 259 | + if let Err(e) = surface.set_swap_interval( | ||
| 260 | + context, | ||
| 261 | + SwapInterval::Wait(std::num::NonZeroU32::new(1).expect("nonzero")), | ||
| 262 | + ) { | ||
| 263 | + eprintln!("vidya: vsync unavailable ({e})"); | ||
| 264 | + } | ||
| 265 | +} | ||
| 266 | + | ||
| 267 | +/// A tessellated frame waiting for the compositor to ask for it. | ||
| 268 | +struct PaintJob { | ||
| 269 | + primitives: Vec<egui::ClippedPrimitive>, | ||
| 270 | + textures_delta: egui::TexturesDelta, | ||
| 271 | + pixels_per_point: f32, | ||
| 272 | + clear: [f32; 4], | ||
| 273 | +} | ||
| 274 | + | ||
| 275 | +/// winit event sink. Owns everything that survives between frames. | ||
| 276 | +struct Handler { | ||
| 277 | + egui_ctx: egui::Context, | ||
| 278 | + theme: Theme, | ||
| 279 | + gl: Option<Gl>, | ||
| 280 | + title: String, | ||
| 281 | + width: u32, | ||
| 282 | + height: u32, | ||
| 283 | + should_close: bool, | ||
| 284 | + /// Set when window creation fails, so `vidya_open` can report it. | ||
| 285 | + error: Option<String>, | ||
| 286 | + /// Whether the compositor is ready for another buffer. Starts open: the | ||
| 287 | + /// initial configure entitles us to the first frame, and the frame callback | ||
| 288 | + /// that arms every later one is only requested *by* presenting — waiting | ||
| 289 | + /// for it before the first present deadlocks. | ||
| 290 | + may_present: bool, | ||
| 291 | + frames: u32, | ||
| 292 | + /// `VIDYA_CAPTURE=<path>`: dump one painted frame, then stop. | ||
| 293 | + capture: Option<String>, | ||
| 294 | + /// `VIDYA_RESIZE_AT` parsed: (frame, width, height), in frame order. | ||
| 295 | + resize_at: Vec<(u32, f64, f64)>, | ||
| 296 | + /// Which frame to dump. `VIDYA_CAPTURE_AT` moves it later than the third, | ||
| 297 | + /// for a screen that only exists once something has loaded. | ||
| 298 | + capture_at: u32, | ||
| 299 | +} | ||
| 300 | + | ||
| 301 | +impl Handler { | ||
| 302 | + /// Paint and present. Only ever called from inside `RedrawRequested`. | ||
| 303 | + fn present(&mut self, job: PaintJob) { | ||
| 304 | + let Some(gl) = self.gl.as_mut() else { | ||
| 305 | + return; | ||
| 306 | + }; | ||
| 307 | + // Between a suspend and the resume after it there is nothing to present | ||
| 308 | + // to. The caller's loop is unaffected; it just paints no frames. | ||
| 309 | + let Some(surface) = gl.surface.as_ref() else { | ||
| 310 | + return; | ||
| 311 | + }; | ||
| 312 | + let size = gl.window.inner_size(); | ||
| 313 | + let dims = [size.width.max(1), size.height.max(1)]; | ||
| 314 | + | ||
| 315 | + gl.painter.clear(dims, job.clear); | ||
| 316 | + gl.painter.paint_and_update_textures( | ||
| 317 | + dims, | ||
| 318 | + job.pixels_per_point, | ||
| 319 | + &job.primitives, | ||
| 320 | + &job.textures_delta, | ||
| 321 | + ); | ||
| 322 | + | ||
| 323 | + self.frames += 1; | ||
| 324 | + // A resize the compositor never sent, for testing a layout that only | ||
| 325 | + // goes wrong when the window changes size under it. `VIDYA_RESIZE_AT` | ||
| 326 | + // is `frame:WIDTHxHEIGHT`, and asks winit for the size the way a drag | ||
| 327 | + // of the window's edge would. | ||
| 328 | + for &(at, w, h) in &self.resize_at { | ||
| 329 | + if self.frames == at { | ||
| 330 | + let _ = gl | ||
| 331 | + .window | ||
| 332 | + .request_inner_size(winit::dpi::LogicalSize::new(w, h)); | ||
| 333 | + } | ||
| 334 | + } | ||
| 335 | + // Third frame: fonts and layout have settled by then. | ||
| 336 | + if self.frames == self.capture_at { | ||
| 337 | + if let Some(path) = self.capture.take() { | ||
| 338 | + capture_frame(gl, dims, &path); | ||
| 339 | + } | ||
| 340 | + } | ||
| 341 | + | ||
| 342 | + // Order matters. `pre_present_notify` lets winit attach its frame | ||
| 343 | + // callback to the commit that `swap_buffers` is about to make, and the | ||
| 344 | + // redraw request that arms the *next* callback only counts once that | ||
| 345 | + // commit has happened. | ||
| 346 | + gl.window.pre_present_notify(); | ||
| 347 | + if let Err(e) = surface.swap_buffers(&gl.context) { | ||
| 348 | + eprintln!("vidya: swap_buffers failed: {e}"); | ||
| 349 | + } | ||
| 350 | + gl.window.request_redraw(); | ||
| 351 | + } | ||
| 352 | +} | ||
| 353 | + | ||
| 354 | +/// Put a Ctrl/Cmd+V back into egui's input when it dropped the keystroke. | ||
| 355 | +/// | ||
| 356 | +/// egui-winit answers a paste shortcut by reading the clipboard's *text* and | ||
| 357 | +/// pushing an `Event::Paste` with it — and returns there, pushing nothing at | ||
| 358 | +/// all when the clipboard holds no text. A copied picture is exactly that | ||
| 359 | +/// case, so the one gesture that means "paste this picture" was the one | ||
| 360 | +/// gesture egui never heard about. | ||
| 361 | +/// | ||
| 362 | +/// The key event it would have pushed goes back in. Nothing in egui acts on a | ||
| 363 | +/// bare Ctrl+V — a text field pastes from `Event::Paste` — so this is inert | ||
| 364 | +/// except to a caller that goes looking for it, which is what `:entry`'s | ||
| 365 | +/// `paste-empty` does. | ||
| 366 | +fn note_paste_shortcut(gl: &mut Gl, event: &WindowEvent) { | ||
| 367 | + let WindowEvent::KeyboardInput { event: key, .. } = event else { | ||
| 368 | + return; | ||
| 369 | + }; | ||
| 370 | + if !key.state.is_pressed() { | ||
| 371 | + return; | ||
| 372 | + } | ||
| 373 | + let modifiers = gl.winit_state.egui_input().modifiers; | ||
| 374 | + if !modifiers.command { | ||
| 375 | + return; | ||
| 376 | + } | ||
| 377 | + // Logical first, physical as the fallback: the same rule egui-winit uses, | ||
| 378 | + // so a layout with no Latin V of its own still pastes from where V sits. | ||
| 379 | + let logical_v = matches!( | ||
| 380 | + &key.logical_key, | ||
| 381 | + winit::keyboard::Key::Character(c) if c.eq_ignore_ascii_case("v") | ||
| 382 | + ); | ||
| 383 | + let physical_v = matches!( | ||
| 384 | + key.physical_key, | ||
| 385 | + winit::keyboard::PhysicalKey::Code(winit::keyboard::KeyCode::KeyV) | ||
| 386 | + ); | ||
| 387 | + if !logical_v && !physical_v { | ||
| 388 | + return; | ||
| 389 | + } | ||
| 390 | + gl.winit_state | ||
| 391 | + .egui_input_mut() | ||
| 392 | + .events | ||
| 393 | + .push(egui::Event::Key { | ||
| 394 | + key: egui::Key::V, | ||
| 395 | + physical_key: None, | ||
| 396 | + pressed: true, | ||
| 397 | + repeat: false, | ||
| 398 | + modifiers, | ||
| 399 | + }); | ||
| 400 | +} | ||
| 401 | + | ||
| 402 | +impl ApplicationHandler for Handler { | ||
| 403 | + fn resumed(&mut self, el: &ActiveEventLoop) { | ||
| 404 | + if let Some(gl) = self.gl.as_mut() { | ||
| 405 | + // Not a first start: the activity came back to the foreground, and | ||
| 406 | + // what it lost while it was away was the surface. | ||
| 407 | + gl.resume(); | ||
| 408 | + // A window that stopped being composited stopped presenting too, | ||
| 409 | + // and the redraw request that arms the next frame callback is only | ||
| 410 | + // made *by* presenting. Nothing would ever ask for the first frame | ||
| 411 | + // back without this. | ||
| 412 | + self.may_present = true; | ||
| 413 | + return; | ||
| 414 | + } | ||
| 415 | + match Gl::create(el, &self.egui_ctx, &self.title, self.width, self.height) { | ||
| 416 | + Ok(gl) => { | ||
| 417 | + vidya_core::apply(&self.egui_ctx, &self.theme); | ||
| 418 | + self.gl = Some(gl); | ||
| 419 | + } | ||
| 420 | + Err(e) => { | ||
| 421 | + self.error = Some(e); | ||
| 422 | + self.should_close = true; | ||
| 423 | + } | ||
| 424 | + } | ||
| 425 | + } | ||
| 426 | + | ||
| 427 | + /// The platform is taking the native window away — Android does this every | ||
| 428 | + /// time the activity leaves the foreground. | ||
| 429 | + fn suspended(&mut self, _el: &ActiveEventLoop) { | ||
| 430 | + if let Some(gl) = self.gl.as_mut() { | ||
| 431 | + gl.suspend(); | ||
| 432 | + } | ||
| 433 | + } | ||
| 434 | + | ||
| 435 | + fn window_event(&mut self, _el: &ActiveEventLoop, _id: WindowId, event: WindowEvent) { | ||
| 436 | + if let Some(gl) = self.gl.as_mut() { | ||
| 437 | + // egui sees every event, including the ones handled below. | ||
| 438 | + let _ = gl.winit_state.on_window_event(&gl.window, &event); | ||
| 439 | + note_paste_shortcut(gl, &event); | ||
| 440 | + | ||
| 441 | + if let WindowEvent::Resized(size) = event { | ||
| 442 | + if size.width > 0 && size.height > 0 { | ||
| 443 | + if let Some(surface) = gl.surface.as_ref() { | ||
| 444 | + gl.window.resize_surface(surface, &gl.context); | ||
| 445 | + } | ||
| 446 | + } | ||
| 447 | + } | ||
| 448 | + } | ||
| 449 | + | ||
| 450 | + match event { | ||
| 451 | + WindowEvent::CloseRequested | WindowEvent::Destroyed => self.should_close = true, | ||
| 452 | + WindowEvent::RedrawRequested => self.may_present = true, | ||
| 453 | + _ => {} | ||
| 454 | + } | ||
| 455 | + } | ||
| 456 | +} | ||
| 457 | + | ||
| 458 | +/// One UI context per process, matching the ABI's window model. | ||
| 459 | +pub struct App { | ||
| 460 | + event_loop: EventLoop<()>, | ||
| 461 | + handler: Handler, | ||
| 462 | + /// Live only between `begin_frame` and `end_frame`. | ||
| 463 | + pub stack: Stack, | ||
| 464 | + frame_budget: Duration, | ||
| 465 | + frame_started: Instant, | ||
| 466 | + /// Frames skipped because nothing was compositing the window. | ||
| 467 | + dropped: u32, | ||
| 468 | + font_generation: u32, | ||
| 469 | +} | ||
| 470 | + | ||
| 471 | +impl App { | ||
| 472 | + pub fn open(width: i32, height: i32, title: &str) -> Result<Self, String> { | ||
| 473 | + let event_loop = build_event_loop()?; | ||
| 474 | + | ||
| 475 | + let mut app = Self { | ||
| 476 | + event_loop, | ||
| 477 | + handler: Handler { | ||
| 478 | + egui_ctx: egui::Context::default(), | ||
| 479 | + theme: Theme::dark(), | ||
| 480 | + gl: None, | ||
| 481 | + title: title.to_owned(), | ||
| 482 | + width: width.max(1) as u32, | ||
| 483 | + height: height.max(1) as u32, | ||
| 484 | + should_close: false, | ||
| 485 | + error: None, | ||
| 486 | + may_present: true, | ||
| 487 | + frames: 0, | ||
| 488 | + capture: std::env::var("VIDYA_CAPTURE").ok(), | ||
| 489 | + resize_at: std::env::var("VIDYA_RESIZE_AT") | ||
| 490 | + .unwrap_or_default() | ||
| 491 | + .split(',') | ||
| 492 | + .filter_map(|step| { | ||
| 493 | + let (at, size) = step.split_once(':')?; | ||
| 494 | + let (w, h) = size.split_once('x')?; | ||
| 495 | + Some((at.parse().ok()?, w.parse().ok()?, h.parse().ok()?)) | ||
| 496 | + }) | ||
| 497 | + .collect(), | ||
| 498 | + capture_at: std::env::var("VIDYA_CAPTURE_AT") | ||
| 499 | + .ok() | ||
| 500 | + .and_then(|v| v.parse().ok()) | ||
| 501 | + .unwrap_or(3), | ||
| 502 | + }, | ||
| 503 | + stack: Stack::default(), | ||
| 504 | + frame_budget: DEFAULT_FRAME_BUDGET, | ||
| 505 | + frame_started: Instant::now(), | ||
| 506 | + dropped: 0, | ||
| 507 | + font_generation: 0, | ||
| 508 | + }; | ||
| 509 | + | ||
| 510 | + // Pump until the platform resumes us and the window exists. | ||
| 511 | + let deadline = Instant::now() + OPEN_TIMEOUT; | ||
| 512 | + while app.handler.gl.is_none() && app.handler.error.is_none() { | ||
| 513 | + if Instant::now() > deadline { | ||
| 514 | + return Err("timed out waiting for a window".to_owned()); | ||
| 515 | + } | ||
| 516 | + app.pump(Duration::from_millis(10)); | ||
| 517 | + } | ||
| 518 | + match app.handler.error.take() { | ||
| 519 | + Some(e) => Err(e), | ||
| 520 | + None => Ok(app), | ||
| 521 | + } | ||
| 522 | + } | ||
| 523 | + | ||
| 524 | + fn pump(&mut self, timeout: Duration) { | ||
| 525 | + self.event_loop | ||
| 526 | + .pump_app_events(Some(timeout), &mut self.handler); | ||
| 527 | + } | ||
| 528 | + | ||
| 529 | + pub fn should_close(&mut self) -> bool { | ||
| 530 | + self.pump(Duration::ZERO); | ||
| 531 | + self.handler.should_close | ||
| 532 | + } | ||
| 533 | + | ||
| 534 | + pub fn theme(&self) -> &Theme { | ||
| 535 | + &self.handler.theme | ||
| 536 | + } | ||
| 537 | + | ||
| 538 | + /// The innermost open node plus the live theme. | ||
| 539 | + /// | ||
| 540 | + /// Returned together because widget calls need both, and they live in | ||
| 541 | + /// different fields — splitting the borrow here keeps the call sites plain. | ||
| 542 | + pub fn ui(&mut self) -> Option<(&mut egui::Ui, &Theme)> { | ||
| 543 | + let theme = &self.handler.theme; | ||
| 544 | + self.stack.top().map(|ui| (ui, theme)) | ||
| 545 | + } | ||
| 546 | + | ||
| 547 | + pub fn set_mode(&mut self, mode: vidya_core::Mode) { | ||
| 548 | + self.handler.theme = match mode { | ||
| 549 | + vidya_core::Mode::Dark => Theme::dark(), | ||
| 550 | + vidya_core::Mode::Light => Theme::light(), | ||
| 551 | + }; | ||
| 552 | + vidya_core::apply(&self.handler.egui_ctx, &self.handler.theme); | ||
| 553 | + } | ||
| 554 | + | ||
| 555 | + pub fn set_target_fps(&mut self, fps: i32) { | ||
| 556 | + self.frame_budget = match fps { | ||
| 557 | + f if f > 0 => Duration::from_secs_f64(1.0 / f as f64), | ||
| 558 | + _ => DEFAULT_FRAME_BUDGET, | ||
| 559 | + }; | ||
| 560 | + } | ||
| 561 | + | ||
| 562 | + /// Install a UI font as the highest-priority proportional family. | ||
| 563 | + /// | ||
| 564 | + /// The symbol fallback installed by [`vidya_core::apply`] stays in place, | ||
| 565 | + /// so punctuation and block art keep rendering. | ||
| 566 | + pub fn load_font(&mut self, path: &str) -> bool { | ||
| 567 | + let Ok(bytes) = std::fs::read(path) else { | ||
| 568 | + return false; | ||
| 569 | + }; | ||
| 570 | + self.font_generation += 1; | ||
| 571 | + // Unique name per load: egui skips a name it already has. | ||
| 572 | + let name = format!("vidya-ui-{}", self.font_generation); | ||
| 573 | + self.handler | ||
| 574 | + .egui_ctx | ||
| 575 | + .add_font(egui::epaint::text::FontInsert::new( | ||
| 576 | + &name, | ||
| 577 | + egui::FontData::from_owned(bytes), | ||
| 578 | + vec![egui::epaint::text::InsertFontFamily { | ||
| 579 | + family: egui::FontFamily::Proportional, | ||
| 580 | + priority: egui::epaint::text::FontPriority::Highest, | ||
| 581 | + }], | ||
| 582 | + )); | ||
| 583 | + true | ||
| 584 | + } | ||
| 585 | + | ||
| 586 | + /// Drain pending input and open an egui pass with a root [`egui::Ui`]. | ||
| 587 | + pub fn begin_frame(&mut self) { | ||
| 588 | + self.pump(Duration::ZERO); | ||
| 589 | + self.frame_started = Instant::now(); | ||
| 590 | + | ||
| 591 | + if self.handler.gl.is_none() || self.stack.is_active() { | ||
| 592 | + // No window, or the caller skipped `vidya_end_frame`. | ||
| 593 | + return; | ||
| 594 | + } | ||
| 595 | + let Some(gl) = self.handler.gl.as_mut() else { | ||
| 596 | + return; | ||
| 597 | + }; | ||
| 598 | + | ||
| 599 | + let input = gl.winit_state.take_egui_input(&gl.window); | ||
| 600 | + let ctx = &self.handler.egui_ctx; | ||
| 601 | + ctx.begin_pass(input); | ||
| 602 | + self.stack.push_root(ctx); | ||
| 603 | + } | ||
| 604 | + | ||
| 605 | + /// Close the pass, then hand the frame to the event loop to present. | ||
| 606 | + pub fn end_frame(&mut self) { | ||
| 607 | + if !self.stack.is_active() { | ||
| 608 | + return; | ||
| 609 | + } | ||
| 610 | + // Close anything the caller left open (a missing `vidya_card_end`). | ||
| 611 | + self.stack.unwind(); | ||
| 612 | + if self.handler.gl.is_none() { | ||
| 613 | + return; | ||
| 614 | + } | ||
| 615 | + | ||
| 616 | + let egui::FullOutput { | ||
| 617 | + platform_output, | ||
| 618 | + textures_delta, | ||
| 619 | + shapes, | ||
| 620 | + pixels_per_point, | ||
| 621 | + .. | ||
| 622 | + } = self.handler.egui_ctx.end_pass(); | ||
| 623 | + let primitives = self.handler.egui_ctx.tessellate(shapes, pixels_per_point); | ||
| 624 | + // Gamma, not linear: `Painter::clear` hands these straight to | ||
| 625 | + // `glClearColor` against an sRGB framebuffer, so a `Rgba::from` | ||
| 626 | + // conversion here would be applied twice and the window would clear to | ||
| 627 | + // near-black instead of the palette's charcoal. | ||
| 628 | + let clear = self | ||
| 629 | + .handler | ||
| 630 | + .theme | ||
| 631 | + .palette | ||
| 632 | + .window_bg | ||
| 633 | + .to_normalized_gamma_f32(); | ||
| 634 | + | ||
| 635 | + if let Some(gl) = self.handler.gl.as_mut() { | ||
| 636 | + gl.winit_state | ||
| 637 | + .handle_platform_output(&gl.window, platform_output); | ||
| 638 | + } | ||
| 639 | + | ||
| 640 | + // Wait for the compositor to want a frame, dispatching events while we | ||
| 641 | + // wait — never inside `swap_buffers`, which would park the thread that | ||
| 642 | + // owes the compositor its replies and stall the session. This wait is | ||
| 643 | + // also what paces the caller's loop to the display. | ||
| 644 | + let deadline = Instant::now() + PRESENT_TIMEOUT; | ||
| 645 | + while !self.handler.may_present && Instant::now() < deadline { | ||
| 646 | + self.pump(Duration::from_millis(2)); | ||
| 647 | + } | ||
| 648 | + | ||
| 649 | + if self.handler.may_present { | ||
| 650 | + self.handler.may_present = false; | ||
| 651 | + self.handler.present(PaintJob { | ||
| 652 | + primitives, | ||
| 653 | + textures_delta, | ||
| 654 | + pixels_per_point, | ||
| 655 | + clear, | ||
| 656 | + }); | ||
| 657 | + } else { | ||
| 658 | + // Nobody is compositing this window (minimized, another workspace). | ||
| 659 | + // Drop the frame rather than force a present that would block. | ||
| 660 | + self.dropped += 1; | ||
| 661 | + if self.dropped == 1 { | ||
| 662 | + eprintln!("vidya: window is not being composited; dropping frames"); | ||
| 663 | + } | ||
| 664 | + } | ||
| 665 | + | ||
| 666 | + // Floor for platforms that do not throttle presents at all. | ||
| 667 | + if let Some(left) = self.frame_budget.checked_sub(self.frame_started.elapsed()) { | ||
| 668 | + std::thread::sleep(left); | ||
| 669 | + } | ||
| 670 | + } | ||
| 671 | +} | ||
| 672 | + | ||
| 673 | +impl Drop for App { | ||
| 674 | + fn drop(&mut self) { | ||
| 675 | + self.stack.unwind(); | ||
| 676 | + if let Some(gl) = self.handler.gl.as_mut() { | ||
| 677 | + gl.painter.destroy(); | ||
| 678 | + } | ||
| 679 | + } | ||
| 680 | +} | ||
| 681 | + | ||
| 682 | +/// Write the painted framebuffer to a binary PPM. | ||
| 683 | +/// | ||
| 684 | +/// A rendering backend is otherwise unverifiable where the compositor refuses | ||
| 685 | +/// screenshots, and in CI where there is nobody to look. Off unless | ||
| 686 | +/// `VIDYA_CAPTURE` names a path. | ||
| 687 | +fn capture_frame(gl: &Gl, dims: [u32; 2], path: &str) { | ||
| 688 | + use glow::HasContext as _; | ||
| 689 | + use std::io::Write as _; | ||
| 690 | + | ||
| 691 | + let [w, h] = dims; | ||
| 692 | + let mut rgba = vec![0u8; (w as usize) * (h as usize) * 4]; | ||
| 693 | + unsafe { | ||
| 694 | + // Drain the pipeline: the pixels need not exist yet. | ||
| 695 | + gl.painter.gl().finish(); | ||
| 696 | + gl.painter.gl().read_pixels( | ||
| 697 | + 0, | ||
| 698 | + 0, | ||
| 699 | + w as i32, | ||
| 700 | + h as i32, | ||
| 701 | + glow::RGBA, | ||
| 702 | + glow::UNSIGNED_BYTE, | ||
| 703 | + glow::PixelPackData::Slice(Some(&mut rgba)), | ||
| 704 | + ); | ||
| 705 | + } | ||
| 706 | + | ||
| 707 | + let mut out = Vec::with_capacity((w as usize) * (h as usize) * 3 + 32); | ||
| 708 | + out.extend_from_slice(format!("P6\n{w} {h}\n255\n").as_bytes()); | ||
| 709 | + // GL origin is bottom-left; PPM is top-down. | ||
| 710 | + for row in (0..h as usize).rev() { | ||
| 711 | + let start = row * w as usize * 4; | ||
| 712 | + for px in rgba[start..start + w as usize * 4].chunks_exact(4) { | ||
| 713 | + out.extend_from_slice(&px[..3]); | ||
| 714 | + } | ||
| 715 | + } | ||
| 716 | + | ||
| 717 | + match std::fs::File::create(path).and_then(|mut f| f.write_all(&out)) { | ||
| 718 | + Ok(()) => eprintln!("vidya: wrote {path}"), | ||
| 719 | + Err(e) => eprintln!("vidya: capture failed: {e}"), | ||
| 720 | + } | ||
| 721 | +} | ||
added
crates/jolt-vidya/src/lib.rs +797 -0 | new file mode 100644 | ||
| @@ -0,0 +1,797 @@ | ||
| 1 | +//! Vidya's C ABI, implemented on the Rust/egui semantic layer. | |
| 2 | +//! | |
| 3 | +//! This is a third backend behind the header in `raylib/include/vidya.h`, | |
| 4 | +//! alongside the direct-raylib and cimgui ones. It exports the same symbols | |
| 5 | +//! from a `cdylib` named `libvidya`, so Jolt — or any other FFI consumer — | |
| 6 | +//! switches backends by shared-library search path alone, with no binding | |
| 7 | +//! changes. | |
| 8 | +//! | |
| 9 | +//! Rules inherited from the ABI: | |
| 10 | +//! | |
| 11 | +//! * one UI context per process; | |
| 12 | +//! * every call stays on the thread that called `vidya_open` (enforced here: | |
| 13 | +//! the context lives in thread-local storage, so calls from other threads are | |
| 14 | +//! inert rather than unsound); | |
| 15 | +//! * only C integers, floats, pointers, and UTF-8 byte strings cross the | |
| 16 | +//! boundary, and nothing on this side retains caller memory past the call. | |
| 17 | +//! | |
| 18 | +//! Panics are caught at the boundary: unwinding into a C or Chez caller would | |
| 19 | +//! be undefined behaviour. | |
| 20 | + | |
| 21 | +#[cfg(target_os = "android")] | |
| 22 | +mod android; | |
| 23 | +mod app; | |
| 24 | +mod tree; | |
| 25 | +mod ui; | |
| 26 | + | |
| 27 | +use std::cell::RefCell; | |
| 28 | +use std::ffi::{c_char, c_float, c_int, CStr, CString}; | |
| 29 | +use std::panic::AssertUnwindSafe; | |
| 30 | + | |
| 31 | +use app::App; | |
| 32 | +use egui::Ui; | |
| 33 | +use tree::{Tree, Value}; | |
| 34 | +use vidya_core::{Mode, Theme}; | |
| 35 | + | |
| 36 | +thread_local! { | |
| 37 | + /// The process's UI context, owned by the thread that opened the window. | |
| 38 | + static APP: RefCell<Option<App>> = const { RefCell::new(None) }; | |
| 39 | +} | |
| 40 | + | |
| 41 | +fn guard<R>(fallback: R, f: impl FnOnce() -> R) -> R { | |
| 42 | + match std::panic::catch_unwind(AssertUnwindSafe(f)) { | |
| 43 | + Ok(value) => value, | |
| 44 | + Err(_) => { | |
| 45 | + eprintln!("vidya: panic caught at the FFI boundary"); | |
| 46 | + fallback | |
| 47 | + } | |
| 48 | + } | |
| 49 | +} | |
| 50 | + | |
| 51 | +fn with_app<R: Copy>(fallback: R, f: impl FnOnce(&mut App) -> R) -> R { | |
| 52 | + guard(fallback, || { | |
| 53 | + APP.with_borrow_mut(|slot| match slot.as_mut() { | |
| 54 | + Some(app) => f(app), | |
| 55 | + None => fallback, | |
| 56 | + }) | |
| 57 | + }) | |
| 58 | +} | |
| 59 | + | |
| 60 | +/// Run `f` against the innermost open UI node. Inert outside a frame. | |
| 61 | +fn with_ui<R: Copy>(fallback: R, f: impl FnOnce(&mut Ui, &Theme) -> R) -> R { | |
| 62 | + with_app(fallback, |app| match app.ui() { | |
| 63 | + Some((ui, theme)) => f(ui, theme), | |
| 64 | + None => fallback, | |
| 65 | + }) | |
| 66 | +} | |
| 67 | + | |
| 68 | +/// # Safety | |
| 69 | +/// `ptr` is null or a NUL-terminated string valid for the duration of the call. | |
| 70 | +unsafe fn borrowed_str(ptr: *const c_char) -> String { | |
| 71 | + if ptr.is_null() { | |
| 72 | + String::new() | |
| 73 | + } else { | |
| 74 | + CStr::from_ptr(ptr).to_string_lossy().into_owned() | |
| 75 | + } | |
| 76 | +} | |
| 77 | + | |
| 78 | +/// Copy `value` into a caller buffer, truncated at a char boundary and always | |
| 79 | +/// NUL-terminated. | |
| 80 | +/// | |
| 81 | +/// # Safety | |
| 82 | +/// `buf` is null or writable for `capacity` bytes. | |
| 83 | +unsafe fn write_buffer(buf: *mut c_char, capacity: usize, value: &str) { | |
| 84 | + if buf.is_null() || capacity == 0 { | |
| 85 | + return; | |
| 86 | + } | |
| 87 | + let mut len = value.len().min(capacity - 1); | |
| 88 | + while len > 0 && !value.is_char_boundary(len) { | |
| 89 | + len -= 1; | |
| 90 | + } | |
| 91 | + std::ptr::copy_nonoverlapping(value.as_ptr().cast::<c_char>(), buf, len); | |
| 92 | + *buf.add(len) = 0; | |
| 93 | +} | |
| 94 | + | |
| 95 | +// ── Window and frame lifecycle ────────────────────────────────────────────── | |
| 96 | + | |
| 97 | +/// # Safety | |
| 98 | +/// `title` is null or a NUL-terminated UTF-8 string. | |
| 99 | +#[no_mangle] | |
| 100 | +pub unsafe extern "C" fn vidya_open(width: c_int, height: c_int, title: *const c_char) -> c_int { | |
| 101 | + let title = borrowed_str(title); | |
| 102 | + guard(0, || { | |
| 103 | + APP.with_borrow_mut(|slot| { | |
| 104 | + if slot.is_some() { | |
| 105 | + eprintln!("vidya: a window is already open"); | |
| 106 | + return 0; | |
| 107 | + } | |
| 108 | + match App::open(width, height, &title) { | |
| 109 | + Ok(app) => { | |
| 110 | + *slot = Some(app); | |
| 111 | + 1 | |
| 112 | + } | |
| 113 | + Err(e) => { | |
| 114 | + eprintln!("vidya: could not open a window: {e}"); | |
| 115 | + 0 | |
| 116 | + } | |
| 117 | + } | |
| 118 | + }) | |
| 119 | + }) | |
| 120 | +} | |
| 121 | + | |
| 122 | +#[no_mangle] | |
| 123 | +pub extern "C" fn vidya_close() { | |
| 124 | + guard((), || APP.with_borrow_mut(|slot| drop(slot.take()))); | |
| 125 | +} | |
| 126 | + | |
| 127 | +#[no_mangle] | |
| 128 | +pub extern "C" fn vidya_should_close() -> c_int { | |
| 129 | + // No window is a closed window, so a caller's loop still terminates. | |
| 130 | + with_app(1, |app| app.should_close() as c_int) | |
| 131 | +} | |
| 132 | + | |
| 133 | +#[no_mangle] | |
| 134 | +pub extern "C" fn vidya_set_target_fps(fps: c_int) { | |
| 135 | + with_app((), |app| app.set_target_fps(fps)); | |
| 136 | +} | |
| 137 | + | |
| 138 | +#[no_mangle] | |
| 139 | +pub extern "C" fn vidya_set_mode(mode: c_int) { | |
| 140 | + let mode = if mode == 1 { Mode::Light } else { Mode::Dark }; | |
| 141 | + with_app((), |app| app.set_mode(mode)); | |
| 142 | +} | |
| 143 | + | |
| 144 | +#[no_mangle] | |
| 145 | +pub extern "C" fn vidya_get_mode() -> c_int { | |
| 146 | + with_app(0, |app| match app.theme().mode { | |
| 147 | + Mode::Dark => 0, | |
| 148 | + Mode::Light => 1, | |
| 149 | + }) | |
| 150 | +} | |
| 151 | + | |
| 152 | +/// `atlas_size` is accepted for ABI compatibility and ignored: egui rasterizes | |
| 153 | +/// each requested size on demand instead of from one fixed atlas. | |
| 154 | +/// | |
| 155 | +/// # Safety | |
| 156 | +/// `path` is null or a NUL-terminated UTF-8 string. | |
| 157 | +#[no_mangle] | |
| 158 | +pub unsafe extern "C" fn vidya_load_font(path: *const c_char, _atlas_size: c_int) -> c_int { | |
| 159 | + let path = borrowed_str(path); | |
| 160 | + with_app(0, |app| app.load_font(&path) as c_int) | |
| 161 | +} | |
| 162 | + | |
| 163 | +#[no_mangle] | |
| 164 | +pub extern "C" fn vidya_begin_frame() { | |
| 165 | + with_app((), |app| app.begin_frame()); | |
| 166 | +} | |
| 167 | + | |
| 168 | +#[no_mangle] | |
| 169 | +pub extern "C" fn vidya_end_frame() { | |
| 170 | + with_app((), |app| app.end_frame()); | |
| 171 | +} | |
| 172 | + | |
| 173 | +// ── Containers ────────────────────────────────────────────────────────────── | |
| 174 | + | |
| 175 | +#[no_mangle] | |
| 176 | +pub extern "C" fn vidya_page_begin(max_width: c_float) { | |
| 177 | + with_app((), |app| { | |
| 178 | + let theme = app.theme().clone(); | |
| 179 | + app.stack.push_page(&theme, max_width); | |
| 180 | + }); | |
| 181 | +} | |
| 182 | + | |
| 183 | +#[no_mangle] | |
| 184 | +pub extern "C" fn vidya_page_end() { | |
| 185 | + with_app((), |app| app.stack.pop()); | |
| 186 | +} | |
| 187 | + | |
| 188 | +#[no_mangle] | |
| 189 | +pub extern "C" fn vidya_card_begin() { | |
| 190 | + with_app((), |app| { | |
| 191 | + let theme = app.theme().clone(); | |
| 192 | + app.stack.push_card(&theme); | |
| 193 | + }); | |
| 194 | +} | |
| 195 | + | |
| 196 | +#[no_mangle] | |
| 197 | +pub extern "C" fn vidya_card_end() { | |
| 198 | + with_app((), |app| app.stack.pop()); | |
| 199 | +} | |
| 200 | + | |
| 201 | +#[no_mangle] | |
| 202 | +pub extern "C" fn vidya_gap(pixels: c_float) { | |
| 203 | + with_ui((), |ui, _| ui::gap(ui, pixels)); | |
| 204 | +} | |
| 205 | + | |
| 206 | +#[no_mangle] | |
| 207 | +pub extern "C" fn vidya_separator() { | |
| 208 | + with_ui((), |ui, _| ui::separator(ui)); | |
| 209 | +} | |
| 210 | + | |
| 211 | +// ── Text roles ────────────────────────────────────────────────────────────── | |
| 212 | + | |
| 213 | +macro_rules! text_role { | |
| 214 | + ($name:ident, $call:path) => { | |
| 215 | + /// # Safety | |
| 216 | + /// `text` is null or a NUL-terminated UTF-8 string. | |
| 217 | + #[no_mangle] | |
| 218 | + pub unsafe extern "C" fn $name(text: *const c_char) { | |
| 219 | + let text = borrowed_str(text); | |
| 220 | + with_ui((), |ui, theme| $call(ui, theme, &text)); | |
| 221 | + } | |
| 222 | + }; | |
| 223 | +} | |
| 224 | + | |
| 225 | +text_role!(vidya_title, vidya_core::title); | |
| 226 | +text_role!(vidya_title_2, vidya_core::title_2); | |
| 227 | +text_role!(vidya_body, vidya_core::body); | |
| 228 | +text_role!(vidya_dim_label, vidya_core::dim_label); | |
| 229 | + | |
| 230 | +// ── Controls ──────────────────────────────────────────────────────────────── | |
| 231 | + | |
| 232 | +/// # Safety | |
| 233 | +/// `label` is null or a NUL-terminated UTF-8 string. | |
| 234 | +#[no_mangle] | |
| 235 | +pub unsafe extern "C" fn vidya_button(label: *const c_char, kind: c_int) -> c_int { | |
| 236 | + let label = borrowed_str(label); | |
| 237 | + with_ui(0, |ui, theme| ui::button(ui, theme, &label, kind) as c_int) | |
| 238 | +} | |
| 239 | + | |
| 240 | +/// Returns 1 when the value changed this frame, writing it back through | |
| 241 | +/// `checked`. | |
| 242 | +/// | |
| 243 | +/// # Safety | |
| 244 | +/// `label` is null or a NUL-terminated UTF-8 string; `checked` is null or a | |
| 245 | +/// writable `int`. | |
| 246 | +#[no_mangle] | |
| 247 | +pub unsafe extern "C" fn vidya_checkbox(label: *const c_char, checked: *mut c_int) -> c_int { | |
| 248 | + if checked.is_null() { | |
| 249 | + return 0; | |
| 250 | + } | |
| 251 | + let label = borrowed_str(label); | |
| 252 | + let current = *checked != 0; | |
| 253 | + let (value, changed) = with_ui((current, false), |ui, theme| { | |
| 254 | + ui::checkbox(ui, theme, current, &label) | |
| 255 | + }); | |
| 256 | + *checked = value as c_int; | |
| 257 | + changed as c_int | |
| 258 | +} | |
| 259 | + | |
| 260 | +/// FFI-friendly variant: returns the value after handling input. | |
| 261 | +/// | |
| 262 | +/// # Safety | |
| 263 | +/// `label` is null or a NUL-terminated UTF-8 string. | |
| 264 | +#[no_mangle] | |
| 265 | +pub unsafe extern "C" fn vidya_checkbox_value(label: *const c_char, checked: c_int) -> c_int { | |
| 266 | + let label = borrowed_str(label); | |
| 267 | + let current = checked != 0; | |
| 268 | + with_ui(current, |ui, theme| ui::checkbox(ui, theme, current, &label).0) as c_int | |
| 269 | +} | |
| 270 | + | |
| 271 | +/// # Safety | |
| 272 | +/// `label` is null or a NUL-terminated UTF-8 string. | |
| 273 | +#[no_mangle] | |
| 274 | +pub unsafe extern "C" fn vidya_status(label: *const c_char, live: c_int) { | |
| 275 | + let label = borrowed_str(label); | |
| 276 | + with_ui((), |ui, theme| ui::status(ui, theme, &label, live != 0)); | |
| 277 | +} | |
| 278 | + | |
| 279 | +/// Edit `text` in place. Returns 1 when the buffer changed this frame. | |
| 280 | +/// | |
| 281 | +/// # Safety | |
| 282 | +/// `text` is null or a NUL-terminated buffer writable for `capacity` bytes; | |
| 283 | +/// `placeholder` is null or a NUL-terminated UTF-8 string. | |
| 284 | +#[no_mangle] | |
| 285 | +pub unsafe extern "C" fn vidya_text_field( | |
| 286 | + text: *mut c_char, | |
| 287 | + capacity: usize, | |
| 288 | + placeholder: *const c_char, | |
| 289 | +) -> c_int { | |
| 290 | + if text.is_null() || capacity == 0 { | |
| 291 | + return 0; | |
| 292 | + } | |
| 293 | + let placeholder = borrowed_str(placeholder); | |
| 294 | + let mut value = borrowed_str(text.cast_const()); | |
| 295 | + | |
| 296 | + let changed = with_ui(false, |ui, theme| { | |
| 297 | + ui::text_field(ui, theme, &mut value, &placeholder).changed() | |
| 298 | + }); | |
| 299 | + if changed { | |
| 300 | + write_buffer(text, capacity, &value); | |
| 301 | + } | |
| 302 | + changed as c_int | |
| 303 | +} | |
| 304 | + | |
| 305 | +// ── Retained node tree ────────────────────────────────────────────────────── | |
| 306 | +// | |
| 307 | +// The second half of this ABI, for reactive callers. See `tree.rs` for why it | |
| 308 | +// exists and `include/vidya_tree.h` for the contract. Everything below is inert | |
| 309 | +// until the caller builds a tree; a program using only the push/pop calls above | |
| 310 | +// never allocates one. | |
| 311 | + | |
| 312 | +thread_local! { | |
| 313 | + /// The node tree, on the same thread as the window by the same rule as | |
| 314 | + /// `APP`. Created on first use — a push/pop caller never pays for it. | |
| 315 | + static TREE: RefCell<Tree> = RefCell::new(Tree::default()); | |
| 316 | + | |
| 317 | + /// Backing store for the `const char *` returns below. Rust owns every | |
| 318 | + /// string that crosses this boundary, so it has to outlive the call that | |
| 319 | + /// returns it without leaking: one slot, overwritten by the next call. | |
| 320 | + static SCRATCH: RefCell<CString> = RefCell::new(CString::default()); | |
| 321 | +} | |
| 322 | + | |
| 323 | +fn with_tree<R: Copy>(fallback: R, f: impl FnOnce(&mut Tree) -> R) -> R { | |
| 324 | + guard(fallback, || TREE.with_borrow_mut(f)) | |
| 325 | +} | |
| 326 | + | |
| 327 | +/// Copy `value` into the scratch slot and return a pointer C can read until the | |
| 328 | +/// next string-returning call. Interior NULs truncate rather than fail. | |
| 329 | +fn scratch(value: &str) -> *const c_char { | |
| 330 | + let owned = CString::new(value).unwrap_or_else(|e| { | |
| 331 | + let mut bytes = e.into_vec(); | |
| 332 | + bytes.truncate(bytes.iter().position(|&b| b == 0).unwrap_or(0)); | |
| 333 | + CString::new(bytes).expect("truncated at the first NUL") | |
| 334 | + }); | |
| 335 | + SCRATCH.with_borrow_mut(|slot| { | |
| 336 | + *slot = owned; | |
| 337 | + slot.as_ptr() | |
| 338 | + }) | |
| 339 | +} | |
| 340 | + | |
| 341 | +#[no_mangle] | |
| 342 | +pub extern "C" fn vidya_tree_root() -> c_int { | |
| 343 | + with_tree(0, |tree| tree.root() as c_int) | |
| 344 | +} | |
| 345 | + | |
| 346 | +/// # Safety | |
| 347 | +/// `tag` is null or a NUL-terminated UTF-8 string. | |
| 348 | +#[no_mangle] | |
| 349 | +pub unsafe extern "C" fn vidya_node_new(tag: *const c_char) -> c_int { | |
| 350 | + let tag = borrowed_str(tag); | |
| 351 | + with_tree(0, |tree| tree.new_node(&tag) as c_int) | |
| 352 | +} | |
| 353 | + | |
| 354 | +#[no_mangle] | |
| 355 | +pub extern "C" fn vidya_node_free(node: c_int) { | |
| 356 | + with_tree((), |tree| tree.free_node(node.max(0) as u32)); | |
| 357 | +} | |
| 358 | + | |
| 359 | +#[no_mangle] | |
| 360 | +pub extern "C" fn vidya_node_exists(node: c_int) -> c_int { | |
| 361 | + with_tree(0, |tree| tree.exists(node.max(0) as u32) as c_int) | |
| 362 | +} | |
| 363 | + | |
| 364 | +/// # Safety | |
| 365 | +/// `key` and `value` are null or NUL-terminated UTF-8 strings. | |
| 366 | +#[no_mangle] | |
| 367 | +pub unsafe extern "C" fn vidya_node_set_str(node: c_int, key: *const c_char, value: *const c_char) { | |
| 368 | + let (key, value) = (borrowed_str(key), borrowed_str(value)); | |
| 369 | + with_tree((), |tree| { | |
| 370 | + tree.set(node.max(0) as u32, &key, Value::Str(value)) | |
| 371 | + }); | |
| 372 | +} | |
| 373 | + | |
| 374 | +/// # Safety | |
| 375 | +/// `key` is null or a NUL-terminated UTF-8 string. | |
| 376 | +#[no_mangle] | |
| 377 | +pub unsafe extern "C" fn vidya_node_set_num(node: c_int, key: *const c_char, value: f64) { | |
| 378 | + let key = borrowed_str(key); | |
| 379 | + with_tree((), |tree| { | |
| 380 | + tree.set(node.max(0) as u32, &key, Value::Num(value)) | |
| 381 | + }); | |
| 382 | +} | |
| 383 | + | |
| 384 | +/// # Safety | |
| 385 | +/// `key` is null or a NUL-terminated UTF-8 string. | |
| 386 | +#[no_mangle] | |
| 387 | +pub unsafe extern "C" fn vidya_node_set_bool(node: c_int, key: *const c_char, value: c_int) { | |
| 388 | + let key = borrowed_str(key); | |
| 389 | + with_tree((), |tree| { | |
| 390 | + tree.set(node.max(0) as u32, &key, Value::Bool(value != 0)) | |
| 391 | + }); | |
| 392 | +} | |
| 393 | + | |
| 394 | +/// Drop every prop, so a re-render starts from a clean slate rather than | |
| 395 | +/// inheriting props the new hiccup no longer sets. | |
| 396 | +#[no_mangle] | |
| 397 | +pub extern "C" fn vidya_node_clear_props(node: c_int) { | |
| 398 | + with_tree((), |tree| tree.clear_props(node.max(0) as u32)); | |
| 399 | +} | |
| 400 | + | |
| 401 | +/// The empty string for a prop that is unset or is not a string. | |
| 402 | +/// | |
| 403 | +/// # Safety | |
| 404 | +/// `key` is null or a NUL-terminated UTF-8 string. The returned pointer is | |
| 405 | +/// valid until the next string-returning call on this thread. | |
| 406 | +#[no_mangle] | |
| 407 | +pub unsafe extern "C" fn vidya_node_get_str(node: c_int, key: *const c_char) -> *const c_char { | |
| 408 | + let key = borrowed_str(key); | |
| 409 | + let value = TREE.with_borrow(|tree| match tree.get(node.max(0) as u32, &key) { | |
| 410 | + Some(Value::Str(s)) => s.clone(), | |
| 411 | + _ => String::new(), | |
| 412 | + }); | |
| 413 | + scratch(&value) | |
| 414 | +} | |
| 415 | + | |
| 416 | +/// # Safety | |
| 417 | +/// `key` is null or a NUL-terminated UTF-8 string. | |
| 418 | +#[no_mangle] | |
| 419 | +pub unsafe extern "C" fn vidya_node_get_num(node: c_int, key: *const c_char) -> f64 { | |
| 420 | + let key = borrowed_str(key); | |
| 421 | + with_tree(0.0, |tree| match tree.get(node.max(0) as u32, &key) { | |
| 422 | + Some(Value::Num(n)) => *n, | |
| 423 | + Some(Value::Bool(true)) => 1.0, | |
| 424 | + _ => 0.0, | |
| 425 | + }) | |
| 426 | +} | |
| 427 | + | |
| 428 | +/// # Safety | |
| 429 | +/// `key` is null or a NUL-terminated UTF-8 string. | |
| 430 | +#[no_mangle] | |
| 431 | +pub unsafe extern "C" fn vidya_node_get_bool(node: c_int, key: *const c_char) -> c_int { | |
| 432 | + let key = borrowed_str(key); | |
| 433 | + with_tree(0, |tree| { | |
| 434 | + (match tree.get(node.max(0) as u32, &key) { | |
| 435 | + Some(Value::Bool(b)) => *b, | |
| 436 | + Some(Value::Num(n)) => *n != 0.0, | |
| 437 | + _ => false, | |
| 438 | + }) as c_int | |
| 439 | + }) | |
| 440 | +} | |
| 441 | + | |
| 442 | +/// The canonical tag name a node was created with; `hbox` and `vbox` both | |
| 443 | +/// answer `box`. The empty string for a node that no longer exists. | |
| 444 | +/// | |
| 445 | +/// # Safety | |
| 446 | +/// The returned pointer is valid until the next string-returning call on this | |
| 447 | +/// thread. | |
| 448 | +#[no_mangle] | |
| 449 | +pub extern "C" fn vidya_node_tag(node: c_int) -> *const c_char { | |
| 450 | + let tag = TREE.with_borrow(|tree| tree.tag_name(node.max(0) as u32).to_owned()); | |
| 451 | + scratch(&tag) | |
| 452 | +} | |
| 453 | + | |
| 454 | +/// The subtree at `node` as hiccup text, for logging and bug reports; `node` 0 | |
| 455 | +/// means the root, so `vidya_tree_dump(0)` is the whole window. | |
| 456 | +/// | |
| 457 | +/// # Safety | |
| 458 | +/// The returned pointer is valid until the next string-returning call on this | |
| 459 | +/// thread. | |
| 460 | +#[no_mangle] | |
| 461 | +pub extern "C" fn vidya_tree_dump(node: c_int) -> *const c_char { | |
| 462 | + let node = node.max(0) as u32; | |
| 463 | + let text = TREE.with_borrow(|tree| { | |
| 464 | + let id = if node == 0 { tree.root() } else { node }; | |
| 465 | + tree.dump(id) | |
| 466 | + }); | |
| 467 | + scratch(&text) | |
| 468 | +} | |
| 469 | + | |
| 470 | +#[no_mangle] | |
| 471 | +pub extern "C" fn vidya_node_child_count(node: c_int) -> c_int { | |
| 472 | + with_tree(0, |tree| tree.child_count(node.max(0) as u32) as c_int) | |
| 473 | +} | |
| 474 | + | |
| 475 | +/// The `index`th child, or 0 when there is none. | |
| 476 | +#[no_mangle] | |
| 477 | +pub extern "C" fn vidya_node_child_at(node: c_int, index: c_int) -> c_int { | |
| 478 | + with_tree(0, |tree| { | |
| 479 | + tree.child_at(node.max(0) as u32, index.max(0) as usize) as c_int | |
| 480 | + }) | |
| 481 | +} | |
| 482 | + | |
| 483 | +#[no_mangle] | |
| 484 | +pub extern "C" fn vidya_node_append(parent: c_int, child: c_int) -> c_int { | |
| 485 | + with_tree(0, |tree| { | |
| 486 | + tree.append(parent.max(0) as u32, child.max(0) as u32) as c_int | |
| 487 | + }) | |
| 488 | +} | |
| 489 | + | |
| 490 | +/// Unparent `child` **and free it**, with everything under it. | |
| 491 | +/// | |
| 492 | +/// glimmer says nothing further about a widget it has removed, so this is where | |
| 493 | +/// a subtree's storage goes back. | |
| 494 | +#[no_mangle] | |
| 495 | +pub extern "C" fn vidya_node_remove(parent: c_int, child: c_int) { | |
| 496 | + with_tree((), |tree| { | |
| 497 | + tree.remove(parent.max(0) as u32, child.max(0) as u32) | |
| 498 | + }); | |
| 499 | +} | |
| 500 | + | |
| 501 | +/// Move `child` to sit immediately after `sibling`; `sibling` 0 means first. | |
| 502 | +#[no_mangle] | |
| 503 | +pub extern "C" fn vidya_node_insert_after(parent: c_int, child: c_int, sibling: c_int) -> c_int { | |
| 504 | + with_tree(0, |tree| { | |
| 505 | + tree.insert_after( | |
| 506 | + parent.max(0) as u32, | |
| 507 | + child.max(0) as u32, | |
| 508 | + sibling.max(0) as u32, | |
| 509 | + ) as c_int | |
| 510 | + }) | |
| 511 | +} | |
| 512 | + | |
| 513 | +/// Put `new_child` where `old_child` was, and free `old_child`. | |
| 514 | +#[no_mangle] | |
| 515 | +pub extern "C" fn vidya_node_replace(parent: c_int, old_child: c_int, new_child: c_int) -> c_int { | |
| 516 | + with_tree(0, |tree| { | |
| 517 | + tree.replace( | |
| 518 | + parent.max(0) as u32, | |
| 519 | + old_child.max(0) as u32, | |
| 520 | + new_child.max(0) as u32, | |
| 521 | + ) as c_int | |
| 522 | + }) | |
| 523 | +} | |
| 524 | + | |
| 525 | +/// Paint the whole tree as one frame: a `vidya_begin_frame`, the walk, and a | |
| 526 | +/// `vidya_end_frame`. Inert with no window open. | |
| 527 | +#[no_mangle] | |
| 528 | +pub extern "C" fn vidya_tree_frame() { | |
| 529 | + with_app((), |app| { | |
| 530 | + app.begin_frame(); | |
| 531 | + TREE.with_borrow_mut(|tree| { | |
| 532 | + if let Some((ui, theme)) = app.ui() { | |
| 533 | + tree.paint(ui, theme); | |
| 534 | + } | |
| 535 | + }); | |
| 536 | + app.end_frame(); | |
| 537 | + }); | |
| 538 | +} | |
| 539 | + | |
| 540 | +/// Dequeue one event, returning 1 while there was one. Its fields are read with | |
| 541 | +/// the accessors below, which describe the most recently dequeued event. | |
| 542 | +#[no_mangle] | |
| 543 | +pub extern "C" fn vidya_tree_poll_event() -> c_int { | |
| 544 | + with_tree(0, |tree| tree.poll() as c_int) | |
| 545 | +} | |
| 546 | + | |
| 547 | +#[no_mangle] | |
| 548 | +pub extern "C" fn vidya_tree_event_node() -> c_int { | |
| 549 | + with_tree(0, |tree| tree.current().map_or(0, |e| e.node) as c_int) | |
| 550 | +} | |
| 551 | + | |
| 552 | +/// The event's name — `click`, `change`, `toggled`, `activate` — or the empty | |
| 553 | +/// string when nothing has been dequeued. | |
| 554 | +/// | |
| 555 | +/// # Safety | |
| 556 | +/// The returned pointer is valid until the next string-returning call on this | |
| 557 | +/// thread. | |
| 558 | +#[no_mangle] | |
| 559 | +pub extern "C" fn vidya_tree_event_name() -> *const c_char { | |
| 560 | + let name = TREE.with_borrow(|tree| tree.current().map_or("", |e| e.name).to_owned()); | |
| 561 | + scratch(&name) | |
| 562 | +} | |
| 563 | + | |
| 564 | +/// # Safety | |
| 565 | +/// The returned pointer is valid until the next string-returning call on this | |
| 566 | +/// thread. | |
| 567 | +#[no_mangle] | |
| 568 | +pub extern "C" fn vidya_tree_event_text() -> *const c_char { | |
| 569 | + let text = TREE.with_borrow(|tree| tree.current().map_or(String::new(), |e| e.text.clone())); | |
| 570 | + scratch(&text) | |
| 571 | +} | |
| 572 | + | |
| 573 | +#[no_mangle] | |
| 574 | +pub extern "C" fn vidya_tree_event_num() -> f64 { | |
| 575 | + with_tree(0.0, |tree| tree.current().map_or(0.0, |e| e.num)) | |
| 576 | +} | |
| 577 | + | |
| 578 | +// ── Live frames ───────────────────────────────────────────────────────────── | |
| 579 | + | |
| 580 | +/// Hand the tree a frame of raw pixels under `key`, painted by any `:image` | |
| 581 | +/// whose `feed` prop names it. Answers 1 when the frame was accepted. | |
| 582 | +/// | |
| 583 | +/// This is the one thing an `:image` could not do: `src` decodes a file and | |
| 584 | +/// caches the texture by its path forever, which is right for a picture in a | |
| 585 | +/// message and useless for a source that produces a new picture thirty times a | |
| 586 | +/// second. A caller that has its own pixels — a camera, a video decoder, a | |
| 587 | +/// renderer — pushes them here instead, and the tag paints the latest. | |
| 588 | +/// | |
| 589 | +/// `rgba` is `width * height * 4` bytes, row-major, 8 bits a channel, | |
| 590 | +/// un-premultiplied. It is copied before this returns, so the caller may reuse | |
| 591 | +/// the buffer immediately; nothing on this side retains it. A length that | |
| 592 | +/// disagrees with the dimensions is refused rather than painted torn. | |
| 593 | +/// | |
| 594 | +/// Frames are coalesced, not queued: one that arrives before the last has been | |
| 595 | +/// painted replaces it. A source faster than the window costs no backlog. | |
| 596 | +/// | |
| 597 | +/// Like the rest of the tree ABI this must be called on the thread that opened | |
| 598 | +/// the window — a frame produced on a decoder thread crosses to the UI thread | |
| 599 | +/// on the caller's side, not this one. | |
| 600 | +/// | |
| 601 | +/// # Safety | |
| 602 | +/// `key` is null or a NUL-terminated UTF-8 string; `rgba` is null or valid for | |
| 603 | +/// reads of `width * height * 4` bytes for the duration of the call. | |
| 604 | +#[no_mangle] | |
| 605 | +pub unsafe extern "C" fn vidya_frame_rgba( | |
| 606 | + key: *const c_char, | |
| 607 | + width: c_int, | |
| 608 | + height: c_int, | |
| 609 | + rgba: *const u8, | |
| 610 | +) -> c_int { | |
| 611 | + let key = borrowed_str(key); | |
| 612 | + if rgba.is_null() || width <= 0 || height <= 0 { | |
| 613 | + return 0; | |
| 614 | + } | |
| 615 | + let len = (width as usize) | |
| 616 | + .saturating_mul(height as usize) | |
| 617 | + .saturating_mul(4); | |
| 618 | + let pixels = std::slice::from_raw_parts(rgba, len); | |
| 619 | + with_tree(0, |tree| { | |
| 620 | + tree.set_frame(&key, width as u32, height as u32, pixels) as c_int | |
| 621 | + }) | |
| 622 | +} | |
| 623 | + | |
| 624 | +/// Forget the feed named `key` and release its texture, answering 1 when there | |
| 625 | +/// was one. Without this the last frame of a source that has stopped keeps | |
| 626 | +/// painting — the participant who left, still on the wall. | |
| 627 | +/// | |
| 628 | +/// # Safety | |
| 629 | +/// `key` is null or a NUL-terminated UTF-8 string. | |
| 630 | +#[no_mangle] | |
| 631 | +pub unsafe extern "C" fn vidya_frame_drop(key: *const c_char) -> c_int { | |
| 632 | + let key = borrowed_str(key); | |
| 633 | + with_tree(0, |tree| tree.drop_frame(&key) as c_int) | |
| 634 | +} | |
| 635 | + | |
| 636 | +// ── Clipboard ─────────────────────────────────────────────────────────────── | |
| 637 | + | |
| 638 | +/// Write the picture on the system clipboard to `path` as a PNG, answering 1 | |
| 639 | +/// when there was one and it was written. | |
| 640 | +/// | |
| 641 | +/// egui carries clipboard *text* into the frame as an event and nothing else, | |
| 642 | +/// so a pasted image has to be asked for rather than waited for: a caller | |
| 643 | +/// binds this to whatever gesture means paste for it, and reads the file. | |
| 644 | +/// PNG because that is what the `:image` node decodes. | |
| 645 | +/// | |
| 646 | +/// Unlike the rest of this ABI it needs no window and no particular thread — | |
| 647 | +/// it talks to the platform clipboard, not to egui. | |
| 648 | +/// | |
| 649 | +/// # Safety | |
| 650 | +/// `path` is null or a NUL-terminated UTF-8 string. | |
| 651 | +#[no_mangle] | |
| 652 | +pub unsafe extern "C" fn vidya_clipboard_image_png(path: *const c_char) -> c_int { | |
| 653 | + let path = borrowed_str(path); | |
| 654 | + guard(0, || { | |
| 655 | + if path.is_empty() { | |
| 656 | + return 0; | |
| 657 | + } | |
| 658 | + clipboard_image_png(&path) as c_int | |
| 659 | + }) | |
| 660 | +} | |
| 661 | + | |
| 662 | +#[cfg(not(target_os = "android"))] | |
| 663 | +fn clipboard_image_png(path: &str) -> bool { | |
| 664 | + let Ok(mut clipboard) = arboard::Clipboard::new() else { | |
| 665 | + return false; | |
| 666 | + }; | |
| 667 | + // An empty clipboard, text on it, or a format the platform will not hand | |
| 668 | + // over as pixels: all of them are "no picture to paste" to the caller. | |
| 669 | + let Ok(image) = clipboard.get_image() else { | |
| 670 | + return false; | |
| 671 | + }; | |
| 672 | + let Ok(file) = std::fs::File::create(path) else { | |
| 673 | + return false; | |
| 674 | + }; | |
| 675 | + let mut encoder = png::Encoder::new( | |
| 676 | + std::io::BufWriter::new(file), | |
| 677 | + image.width as u32, | |
| 678 | + image.height as u32, | |
| 679 | + ); | |
| 680 | + encoder.set_color(png::ColorType::Rgba); | |
| 681 | + encoder.set_depth(png::BitDepth::Eight); | |
| 682 | + let written = encoder | |
| 683 | + .write_header() | |
| 684 | + .and_then(|mut writer| writer.write_image_data(&image.bytes)) | |
| 685 | + .is_ok(); | |
| 686 | + // A half-written file is worse than none: the caller would upload it. | |
| 687 | + if !written { | |
| 688 | + let _ = std::fs::remove_file(path); | |
| 689 | + } | |
| 690 | + written | |
| 691 | +} | |
| 692 | + | |
| 693 | +/// Android has no clipboard of images to read, and arboard no backend for it. | |
| 694 | +#[cfg(target_os = "android")] | |
| 695 | +fn clipboard_image_png(_path: &str) -> bool { | |
| 696 | + false | |
| 697 | +} | |
| 698 | + | |
| 699 | +/// Hand a URL to whatever shows web pages here; 1 when something took it. | |
| 700 | +/// | |
| 701 | +/// A sign-in flow leaves the app for a browser and comes back, so the app needs | |
| 702 | +/// a way to say "open this". What that means is the platform's business, not | |
| 703 | +/// the caller's: an `xdg-open`/`open` on the desktop, and on Android an | |
| 704 | +/// ACTION_VIEW intent, which is a JNI call — a shelled-out `am start` is | |
| 705 | +/// refused there, since `am` names `com.android.shell` as its calling package | |
| 706 | +/// and that is not the app's uid. | |
| 707 | +/// | |
| 708 | +/// Like the clipboard call this needs no window and no particular thread. | |
| 709 | +/// | |
| 710 | +/// # Safety | |
| 711 | +/// `url` is null or a NUL-terminated UTF-8 string. | |
| 712 | +#[no_mangle] | |
| 713 | +pub unsafe extern "C" fn vidya_open_url(url: *const c_char) -> c_int { | |
| 714 | + let url = borrowed_str(url); | |
| 715 | + guard(0, || { | |
| 716 | + if url.is_empty() { | |
| 717 | + return 0; | |
| 718 | + } | |
| 719 | + open_url(&url) as c_int | |
| 720 | + }) | |
| 721 | +} | |
| 722 | + | |
| 723 | +#[cfg(not(target_os = "android"))] | |
| 724 | +fn open_url(url: &str) -> bool { | |
| 725 | + let opener = if cfg!(target_os = "macos") { | |
| 726 | + "open" | |
| 727 | + } else { | |
| 728 | + "xdg-open" | |
| 729 | + }; | |
| 730 | + // Spawned, not waited on: the browser outlives the call, and on some | |
| 731 | + // desktops the opener itself stays in the foreground for as long as it | |
| 732 | + // does. | |
| 733 | + std::process::Command::new(opener) | |
| 734 | + .arg(url) | |
| 735 | + .stdout(std::process::Stdio::null()) | |
| 736 | + .stderr(std::process::Stdio::null()) | |
| 737 | + .spawn() | |
| 738 | + .is_ok() | |
| 739 | +} | |
| 740 | + | |
| 741 | +/// `startActivity(new Intent(ACTION_VIEW, Uri.parse(url)))` on the activity the | |
| 742 | +/// glue holds. Any JNI failure — or a device with nothing that answers the | |
| 743 | +/// intent — is a plain false; the caller shows the URL instead. | |
| 744 | +#[cfg(target_os = "android")] | |
| 745 | +fn open_url(url: &str) -> bool { | |
| 746 | + use jni::objects::JObject; | |
| 747 | + use jni::{jni_sig, jni_str}; | |
| 748 | + | |
| 749 | + let Some(app) = android::android_app() else { | |
| 750 | + android::warn("open_url: no AndroidApp handle"); | |
| 751 | + return false; | |
| 752 | + }; | |
| 753 | + // SAFETY: the glue owns both handles for the life of the activity, and | |
| 754 | + // hands them out as raw pointers for exactly this. | |
| 755 | + let vm = unsafe { jni::JavaVM::from_raw(app.vm_as_ptr().cast()) }; | |
| 756 | + let activity_ptr = app.activity_as_ptr().cast(); | |
| 757 | + | |
| 758 | + let started: jni::errors::Result<()> = vm.attach_current_thread(|env| { | |
| 759 | + // SAFETY: the activity outlives this frame, and the reference is a | |
| 760 | + // borrow of the glue's own, not one this side owns. | |
| 761 | + let activity = unsafe { JObject::from_raw(env, activity_ptr) }; | |
| 762 | + let url = env.new_string(url)?; | |
| 763 | + let uri = env | |
| 764 | + .call_static_method( | |
| 765 | + jni_str!("android/net/Uri"), | |
| 766 | + jni_str!("parse"), | |
| 767 | + jni_sig!("(Ljava/lang/String;)Landroid/net/Uri;"), | |
| 768 | + &[(&url).into()], | |
| 769 | + )? | |
| 770 | + .l()?; | |
| 771 | + let action = env.new_string("android.intent.action.VIEW")?; | |
| 772 | + let intent = env.new_object( | |
| 773 | + jni_str!("android/content/Intent"), | |
| 774 | + jni_sig!("(Ljava/lang/String;Landroid/net/Uri;)V"), | |
| 775 | + &[(&action).into(), (&uri).into()], | |
| 776 | + )?; | |
| 777 | + env.call_method( | |
| 778 | + &activity, | |
| 779 | + jni_str!("startActivity"), | |
| 780 | + jni_sig!("(Landroid/content/Intent;)V"), | |
| 781 | + &[(&intent).into()], | |
| 782 | + )?; | |
| 783 | + // An ActivityNotFoundException is thrown, not returned. Leaving it | |
| 784 | + // pending would fail the next JNI call on this thread, whoever made | |
| 785 | + // it, so it is caught here and read as "nothing took the URL". | |
| 786 | + if env.exception_check() { | |
| 787 | + env.exception_clear(); | |
| 788 | + return Err(jni::errors::Error::JavaException); | |
| 789 | + } | |
| 790 | + Ok(()) | |
| 791 | + }); | |
| 792 | + | |
| 793 | + if let Err(err) = &started { | |
| 794 | + android::warn(&format!("vidya_open_url: {err}")); | |
| 795 | + } | |
| 796 | + started.is_ok() | |
| 797 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,797 @@ | |||
| 1 | +//! Vidya's C ABI, implemented on the Rust/egui semantic layer. | ||
| 2 | +//! | ||
| 3 | +//! This is a third backend behind the header in `raylib/include/vidya.h`, | ||
| 4 | +//! alongside the direct-raylib and cimgui ones. It exports the same symbols | ||
| 5 | +//! from a `cdylib` named `libvidya`, so Jolt — or any other FFI consumer — | ||
| 6 | +//! switches backends by shared-library search path alone, with no binding | ||
| 7 | +//! changes. | ||
| 8 | +//! | ||
| 9 | +//! Rules inherited from the ABI: | ||
| 10 | +//! | ||
| 11 | +//! * one UI context per process; | ||
| 12 | +//! * every call stays on the thread that called `vidya_open` (enforced here: | ||
| 13 | +//! the context lives in thread-local storage, so calls from other threads are | ||
| 14 | +//! inert rather than unsound); | ||
| 15 | +//! * only C integers, floats, pointers, and UTF-8 byte strings cross the | ||
| 16 | +//! boundary, and nothing on this side retains caller memory past the call. | ||
| 17 | +//! | ||
| 18 | +//! Panics are caught at the boundary: unwinding into a C or Chez caller would | ||
| 19 | +//! be undefined behaviour. | ||
| 20 | + | ||
| 21 | +#[cfg(target_os = "android")] | ||
| 22 | +mod android; | ||
| 23 | +mod app; | ||
| 24 | +mod tree; | ||
| 25 | +mod ui; | ||
| 26 | + | ||
| 27 | +use std::cell::RefCell; | ||
| 28 | +use std::ffi::{c_char, c_float, c_int, CStr, CString}; | ||
| 29 | +use std::panic::AssertUnwindSafe; | ||
| 30 | + | ||
| 31 | +use app::App; | ||
| 32 | +use egui::Ui; | ||
| 33 | +use tree::{Tree, Value}; | ||
| 34 | +use vidya_core::{Mode, Theme}; | ||
| 35 | + | ||
| 36 | +thread_local! { | ||
| 37 | + /// The process's UI context, owned by the thread that opened the window. | ||
| 38 | + static APP: RefCell<Option<App>> = const { RefCell::new(None) }; | ||
| 39 | +} | ||
| 40 | + | ||
| 41 | +fn guard<R>(fallback: R, f: impl FnOnce() -> R) -> R { | ||
| 42 | + match std::panic::catch_unwind(AssertUnwindSafe(f)) { | ||
| 43 | + Ok(value) => value, | ||
| 44 | + Err(_) => { | ||
| 45 | + eprintln!("vidya: panic caught at the FFI boundary"); | ||
| 46 | + fallback | ||
| 47 | + } | ||
| 48 | + } | ||
| 49 | +} | ||
| 50 | + | ||
| 51 | +fn with_app<R: Copy>(fallback: R, f: impl FnOnce(&mut App) -> R) -> R { | ||
| 52 | + guard(fallback, || { | ||
| 53 | + APP.with_borrow_mut(|slot| match slot.as_mut() { | ||
| 54 | + Some(app) => f(app), | ||
| 55 | + None => fallback, | ||
| 56 | + }) | ||
| 57 | + }) | ||
| 58 | +} | ||
| 59 | + | ||
| 60 | +/// Run `f` against the innermost open UI node. Inert outside a frame. | ||
| 61 | +fn with_ui<R: Copy>(fallback: R, f: impl FnOnce(&mut Ui, &Theme) -> R) -> R { | ||
| 62 | + with_app(fallback, |app| match app.ui() { | ||
| 63 | + Some((ui, theme)) => f(ui, theme), | ||
| 64 | + None => fallback, | ||
| 65 | + }) | ||
| 66 | +} | ||
| 67 | + | ||
| 68 | +/// # Safety | ||
| 69 | +/// `ptr` is null or a NUL-terminated string valid for the duration of the call. | ||
| 70 | +unsafe fn borrowed_str(ptr: *const c_char) -> String { | ||
| 71 | + if ptr.is_null() { | ||
| 72 | + String::new() | ||
| 73 | + } else { | ||
| 74 | + CStr::from_ptr(ptr).to_string_lossy().into_owned() | ||
| 75 | + } | ||
| 76 | +} | ||
| 77 | + | ||
| 78 | +/// Copy `value` into a caller buffer, truncated at a char boundary and always | ||
| 79 | +/// NUL-terminated. | ||
| 80 | +/// | ||
| 81 | +/// # Safety | ||
| 82 | +/// `buf` is null or writable for `capacity` bytes. | ||
| 83 | +unsafe fn write_buffer(buf: *mut c_char, capacity: usize, value: &str) { | ||
| 84 | + if buf.is_null() || capacity == 0 { | ||
| 85 | + return; | ||
| 86 | + } | ||
| 87 | + let mut len = value.len().min(capacity - 1); | ||
| 88 | + while len > 0 && !value.is_char_boundary(len) { | ||
| 89 | + len -= 1; | ||
| 90 | + } | ||
| 91 | + std::ptr::copy_nonoverlapping(value.as_ptr().cast::<c_char>(), buf, len); | ||
| 92 | + *buf.add(len) = 0; | ||
| 93 | +} | ||
| 94 | + | ||
| 95 | +// ── Window and frame lifecycle ────────────────────────────────────────────── | ||
| 96 | + | ||
| 97 | +/// # Safety | ||
| 98 | +/// `title` is null or a NUL-terminated UTF-8 string. | ||
| 99 | +#[no_mangle] | ||
| 100 | +pub unsafe extern "C" fn vidya_open(width: c_int, height: c_int, title: *const c_char) -> c_int { | ||
| 101 | + let title = borrowed_str(title); | ||
| 102 | + guard(0, || { | ||
| 103 | + APP.with_borrow_mut(|slot| { | ||
| 104 | + if slot.is_some() { | ||
| 105 | + eprintln!("vidya: a window is already open"); | ||
| 106 | + return 0; | ||
| 107 | + } | ||
| 108 | + match App::open(width, height, &title) { | ||
| 109 | + Ok(app) => { | ||
| 110 | + *slot = Some(app); | ||
| 111 | + 1 | ||
| 112 | + } | ||
| 113 | + Err(e) => { | ||
| 114 | + eprintln!("vidya: could not open a window: {e}"); | ||
| 115 | + 0 | ||
| 116 | + } | ||
| 117 | + } | ||
| 118 | + }) | ||
| 119 | + }) | ||
| 120 | +} | ||
| 121 | + | ||
| 122 | +#[no_mangle] | ||
| 123 | +pub extern "C" fn vidya_close() { | ||
| 124 | + guard((), || APP.with_borrow_mut(|slot| drop(slot.take()))); | ||
| 125 | +} | ||
| 126 | + | ||
| 127 | +#[no_mangle] | ||
| 128 | +pub extern "C" fn vidya_should_close() -> c_int { | ||
| 129 | + // No window is a closed window, so a caller's loop still terminates. | ||
| 130 | + with_app(1, |app| app.should_close() as c_int) | ||
| 131 | +} | ||
| 132 | + | ||
| 133 | +#[no_mangle] | ||
| 134 | +pub extern "C" fn vidya_set_target_fps(fps: c_int) { | ||
| 135 | + with_app((), |app| app.set_target_fps(fps)); | ||
| 136 | +} | ||
| 137 | + | ||
| 138 | +#[no_mangle] | ||
| 139 | +pub extern "C" fn vidya_set_mode(mode: c_int) { | ||
| 140 | + let mode = if mode == 1 { Mode::Light } else { Mode::Dark }; | ||
| 141 | + with_app((), |app| app.set_mode(mode)); | ||
| 142 | +} | ||
| 143 | + | ||
| 144 | +#[no_mangle] | ||
| 145 | +pub extern "C" fn vidya_get_mode() -> c_int { | ||
| 146 | + with_app(0, |app| match app.theme().mode { | ||
| 147 | + Mode::Dark => 0, | ||
| 148 | + Mode::Light => 1, | ||
| 149 | + }) | ||
| 150 | +} | ||
| 151 | + | ||
| 152 | +/// `atlas_size` is accepted for ABI compatibility and ignored: egui rasterizes | ||
| 153 | +/// each requested size on demand instead of from one fixed atlas. | ||
| 154 | +/// | ||
| 155 | +/// # Safety | ||
| 156 | +/// `path` is null or a NUL-terminated UTF-8 string. | ||
| 157 | +#[no_mangle] | ||
| 158 | +pub unsafe extern "C" fn vidya_load_font(path: *const c_char, _atlas_size: c_int) -> c_int { | ||
| 159 | + let path = borrowed_str(path); | ||
| 160 | + with_app(0, |app| app.load_font(&path) as c_int) | ||
| 161 | +} | ||
| 162 | + | ||
| 163 | +#[no_mangle] | ||
| 164 | +pub extern "C" fn vidya_begin_frame() { | ||
| 165 | + with_app((), |app| app.begin_frame()); | ||
| 166 | +} | ||
| 167 | + | ||
| 168 | +#[no_mangle] | ||
| 169 | +pub extern "C" fn vidya_end_frame() { | ||
| 170 | + with_app((), |app| app.end_frame()); | ||
| 171 | +} | ||
| 172 | + | ||
| 173 | +// ── Containers ────────────────────────────────────────────────────────────── | ||
| 174 | + | ||
| 175 | +#[no_mangle] | ||
| 176 | +pub extern "C" fn vidya_page_begin(max_width: c_float) { | ||
| 177 | + with_app((), |app| { | ||
| 178 | + let theme = app.theme().clone(); | ||
| 179 | + app.stack.push_page(&theme, max_width); | ||
| 180 | + }); | ||
| 181 | +} | ||
| 182 | + | ||
| 183 | +#[no_mangle] | ||
| 184 | +pub extern "C" fn vidya_page_end() { | ||
| 185 | + with_app((), |app| app.stack.pop()); | ||
| 186 | +} | ||
| 187 | + | ||
| 188 | +#[no_mangle] | ||
| 189 | +pub extern "C" fn vidya_card_begin() { | ||
| 190 | + with_app((), |app| { | ||
| 191 | + let theme = app.theme().clone(); | ||
| 192 | + app.stack.push_card(&theme); | ||
| 193 | + }); | ||
| 194 | +} | ||
| 195 | + | ||
| 196 | +#[no_mangle] | ||
| 197 | +pub extern "C" fn vidya_card_end() { | ||
| 198 | + with_app((), |app| app.stack.pop()); | ||
| 199 | +} | ||
| 200 | + | ||
| 201 | +#[no_mangle] | ||
| 202 | +pub extern "C" fn vidya_gap(pixels: c_float) { | ||
| 203 | + with_ui((), |ui, _| ui::gap(ui, pixels)); | ||
| 204 | +} | ||
| 205 | + | ||
| 206 | +#[no_mangle] | ||
| 207 | +pub extern "C" fn vidya_separator() { | ||
| 208 | + with_ui((), |ui, _| ui::separator(ui)); | ||
| 209 | +} | ||
| 210 | + | ||
| 211 | +// ── Text roles ────────────────────────────────────────────────────────────── | ||
| 212 | + | ||
| 213 | +macro_rules! text_role { | ||
| 214 | + ($name:ident, $call:path) => { | ||
| 215 | + /// # Safety | ||
| 216 | + /// `text` is null or a NUL-terminated UTF-8 string. | ||
| 217 | + #[no_mangle] | ||
| 218 | + pub unsafe extern "C" fn $name(text: *const c_char) { | ||
| 219 | + let text = borrowed_str(text); | ||
| 220 | + with_ui((), |ui, theme| $call(ui, theme, &text)); | ||
| 221 | + } | ||
| 222 | + }; | ||
| 223 | +} | ||
| 224 | + | ||
| 225 | +text_role!(vidya_title, vidya_core::title); | ||
| 226 | +text_role!(vidya_title_2, vidya_core::title_2); | ||
| 227 | +text_role!(vidya_body, vidya_core::body); | ||
| 228 | +text_role!(vidya_dim_label, vidya_core::dim_label); | ||
| 229 | + | ||
| 230 | +// ── Controls ──────────────────────────────────────────────────────────────── | ||
| 231 | + | ||
| 232 | +/// # Safety | ||
| 233 | +/// `label` is null or a NUL-terminated UTF-8 string. | ||
| 234 | +#[no_mangle] | ||
| 235 | +pub unsafe extern "C" fn vidya_button(label: *const c_char, kind: c_int) -> c_int { | ||
| 236 | + let label = borrowed_str(label); | ||
| 237 | + with_ui(0, |ui, theme| ui::button(ui, theme, &label, kind) as c_int) | ||
| 238 | +} | ||
| 239 | + | ||
| 240 | +/// Returns 1 when the value changed this frame, writing it back through | ||
| 241 | +/// `checked`. | ||
| 242 | +/// | ||
| 243 | +/// # Safety | ||
| 244 | +/// `label` is null or a NUL-terminated UTF-8 string; `checked` is null or a | ||
| 245 | +/// writable `int`. | ||
| 246 | +#[no_mangle] | ||
| 247 | +pub unsafe extern "C" fn vidya_checkbox(label: *const c_char, checked: *mut c_int) -> c_int { | ||
| 248 | + if checked.is_null() { | ||
| 249 | + return 0; | ||
| 250 | + } | ||
| 251 | + let label = borrowed_str(label); | ||
| 252 | + let current = *checked != 0; | ||
| 253 | + let (value, changed) = with_ui((current, false), |ui, theme| { | ||
| 254 | + ui::checkbox(ui, theme, current, &label) | ||
| 255 | + }); | ||
| 256 | + *checked = value as c_int; | ||
| 257 | + changed as c_int | ||
| 258 | +} | ||
| 259 | + | ||
| 260 | +/// FFI-friendly variant: returns the value after handling input. | ||
| 261 | +/// | ||
| 262 | +/// # Safety | ||
| 263 | +/// `label` is null or a NUL-terminated UTF-8 string. | ||
| 264 | +#[no_mangle] | ||
| 265 | +pub unsafe extern "C" fn vidya_checkbox_value(label: *const c_char, checked: c_int) -> c_int { | ||
| 266 | + let label = borrowed_str(label); | ||
| 267 | + let current = checked != 0; | ||
| 268 | + with_ui(current, |ui, theme| ui::checkbox(ui, theme, current, &label).0) as c_int | ||
| 269 | +} | ||
| 270 | + | ||
| 271 | +/// # Safety | ||
| 272 | +/// `label` is null or a NUL-terminated UTF-8 string. | ||
| 273 | +#[no_mangle] | ||
| 274 | +pub unsafe extern "C" fn vidya_status(label: *const c_char, live: c_int) { | ||
| 275 | + let label = borrowed_str(label); | ||
| 276 | + with_ui((), |ui, theme| ui::status(ui, theme, &label, live != 0)); | ||
| 277 | +} | ||
| 278 | + | ||
| 279 | +/// Edit `text` in place. Returns 1 when the buffer changed this frame. | ||
| 280 | +/// | ||
| 281 | +/// # Safety | ||
| 282 | +/// `text` is null or a NUL-terminated buffer writable for `capacity` bytes; | ||
| 283 | +/// `placeholder` is null or a NUL-terminated UTF-8 string. | ||
| 284 | +#[no_mangle] | ||
| 285 | +pub unsafe extern "C" fn vidya_text_field( | ||
| 286 | + text: *mut c_char, | ||
| 287 | + capacity: usize, | ||
| 288 | + placeholder: *const c_char, | ||
| 289 | +) -> c_int { | ||
| 290 | + if text.is_null() || capacity == 0 { | ||
| 291 | + return 0; | ||
| 292 | + } | ||
| 293 | + let placeholder = borrowed_str(placeholder); | ||
| 294 | + let mut value = borrowed_str(text.cast_const()); | ||
| 295 | + | ||
| 296 | + let changed = with_ui(false, |ui, theme| { | ||
| 297 | + ui::text_field(ui, theme, &mut value, &placeholder).changed() | ||
| 298 | + }); | ||
| 299 | + if changed { | ||
| 300 | + write_buffer(text, capacity, &value); | ||
| 301 | + } | ||
| 302 | + changed as c_int | ||
| 303 | +} | ||
| 304 | + | ||
| 305 | +// ── Retained node tree ────────────────────────────────────────────────────── | ||
| 306 | +// | ||
| 307 | +// The second half of this ABI, for reactive callers. See `tree.rs` for why it | ||
| 308 | +// exists and `include/vidya_tree.h` for the contract. Everything below is inert | ||
| 309 | +// until the caller builds a tree; a program using only the push/pop calls above | ||
| 310 | +// never allocates one. | ||
| 311 | + | ||
| 312 | +thread_local! { | ||
| 313 | + /// The node tree, on the same thread as the window by the same rule as | ||
| 314 | + /// `APP`. Created on first use — a push/pop caller never pays for it. | ||
| 315 | + static TREE: RefCell<Tree> = RefCell::new(Tree::default()); | ||
| 316 | + | ||
| 317 | + /// Backing store for the `const char *` returns below. Rust owns every | ||
| 318 | + /// string that crosses this boundary, so it has to outlive the call that | ||
| 319 | + /// returns it without leaking: one slot, overwritten by the next call. | ||
| 320 | + static SCRATCH: RefCell<CString> = RefCell::new(CString::default()); | ||
| 321 | +} | ||
| 322 | + | ||
| 323 | +fn with_tree<R: Copy>(fallback: R, f: impl FnOnce(&mut Tree) -> R) -> R { | ||
| 324 | + guard(fallback, || TREE.with_borrow_mut(f)) | ||
| 325 | +} | ||
| 326 | + | ||
| 327 | +/// Copy `value` into the scratch slot and return a pointer C can read until the | ||
| 328 | +/// next string-returning call. Interior NULs truncate rather than fail. | ||
| 329 | +fn scratch(value: &str) -> *const c_char { | ||
| 330 | + let owned = CString::new(value).unwrap_or_else(|e| { | ||
| 331 | + let mut bytes = e.into_vec(); | ||
| 332 | + bytes.truncate(bytes.iter().position(|&b| b == 0).unwrap_or(0)); | ||
| 333 | + CString::new(bytes).expect("truncated at the first NUL") | ||
| 334 | + }); | ||
| 335 | + SCRATCH.with_borrow_mut(|slot| { | ||
| 336 | + *slot = owned; | ||
| 337 | + slot.as_ptr() | ||
| 338 | + }) | ||
| 339 | +} | ||
| 340 | + | ||
| 341 | +#[no_mangle] | ||
| 342 | +pub extern "C" fn vidya_tree_root() -> c_int { | ||
| 343 | + with_tree(0, |tree| tree.root() as c_int) | ||
| 344 | +} | ||
| 345 | + | ||
| 346 | +/// # Safety | ||
| 347 | +/// `tag` is null or a NUL-terminated UTF-8 string. | ||
| 348 | +#[no_mangle] | ||
| 349 | +pub unsafe extern "C" fn vidya_node_new(tag: *const c_char) -> c_int { | ||
| 350 | + let tag = borrowed_str(tag); | ||
| 351 | + with_tree(0, |tree| tree.new_node(&tag) as c_int) | ||
| 352 | +} | ||
| 353 | + | ||
| 354 | +#[no_mangle] | ||
| 355 | +pub extern "C" fn vidya_node_free(node: c_int) { | ||
| 356 | + with_tree((), |tree| tree.free_node(node.max(0) as u32)); | ||
| 357 | +} | ||
| 358 | + | ||
| 359 | +#[no_mangle] | ||
| 360 | +pub extern "C" fn vidya_node_exists(node: c_int) -> c_int { | ||
| 361 | + with_tree(0, |tree| tree.exists(node.max(0) as u32) as c_int) | ||
| 362 | +} | ||
| 363 | + | ||
| 364 | +/// # Safety | ||
| 365 | +/// `key` and `value` are null or NUL-terminated UTF-8 strings. | ||
| 366 | +#[no_mangle] | ||
| 367 | +pub unsafe extern "C" fn vidya_node_set_str(node: c_int, key: *const c_char, value: *const c_char) { | ||
| 368 | + let (key, value) = (borrowed_str(key), borrowed_str(value)); | ||
| 369 | + with_tree((), |tree| { | ||
| 370 | + tree.set(node.max(0) as u32, &key, Value::Str(value)) | ||
| 371 | + }); | ||
| 372 | +} | ||
| 373 | + | ||
| 374 | +/// # Safety | ||
| 375 | +/// `key` is null or a NUL-terminated UTF-8 string. | ||
| 376 | +#[no_mangle] | ||
| 377 | +pub unsafe extern "C" fn vidya_node_set_num(node: c_int, key: *const c_char, value: f64) { | ||
| 378 | + let key = borrowed_str(key); | ||
| 379 | + with_tree((), |tree| { | ||
| 380 | + tree.set(node.max(0) as u32, &key, Value::Num(value)) | ||
| 381 | + }); | ||
| 382 | +} | ||
| 383 | + | ||
| 384 | +/// # Safety | ||
| 385 | +/// `key` is null or a NUL-terminated UTF-8 string. | ||
| 386 | +#[no_mangle] | ||
| 387 | +pub unsafe extern "C" fn vidya_node_set_bool(node: c_int, key: *const c_char, value: c_int) { | ||
| 388 | + let key = borrowed_str(key); | ||
| 389 | + with_tree((), |tree| { | ||
| 390 | + tree.set(node.max(0) as u32, &key, Value::Bool(value != 0)) | ||
| 391 | + }); | ||
| 392 | +} | ||
| 393 | + | ||
| 394 | +/// Drop every prop, so a re-render starts from a clean slate rather than | ||
| 395 | +/// inheriting props the new hiccup no longer sets. | ||
| 396 | +#[no_mangle] | ||
| 397 | +pub extern "C" fn vidya_node_clear_props(node: c_int) { | ||
| 398 | + with_tree((), |tree| tree.clear_props(node.max(0) as u32)); | ||
| 399 | +} | ||
| 400 | + | ||
| 401 | +/// The empty string for a prop that is unset or is not a string. | ||
| 402 | +/// | ||
| 403 | +/// # Safety | ||
| 404 | +/// `key` is null or a NUL-terminated UTF-8 string. The returned pointer is | ||
| 405 | +/// valid until the next string-returning call on this thread. | ||
| 406 | +#[no_mangle] | ||
| 407 | +pub unsafe extern "C" fn vidya_node_get_str(node: c_int, key: *const c_char) -> *const c_char { | ||
| 408 | + let key = borrowed_str(key); | ||
| 409 | + let value = TREE.with_borrow(|tree| match tree.get(node.max(0) as u32, &key) { | ||
| 410 | + Some(Value::Str(s)) => s.clone(), | ||
| 411 | + _ => String::new(), | ||
| 412 | + }); | ||
| 413 | + scratch(&value) | ||
| 414 | +} | ||
| 415 | + | ||
| 416 | +/// # Safety | ||
| 417 | +/// `key` is null or a NUL-terminated UTF-8 string. | ||
| 418 | +#[no_mangle] | ||
| 419 | +pub unsafe extern "C" fn vidya_node_get_num(node: c_int, key: *const c_char) -> f64 { | ||
| 420 | + let key = borrowed_str(key); | ||
| 421 | + with_tree(0.0, |tree| match tree.get(node.max(0) as u32, &key) { | ||
| 422 | + Some(Value::Num(n)) => *n, | ||
| 423 | + Some(Value::Bool(true)) => 1.0, | ||
| 424 | + _ => 0.0, | ||
| 425 | + }) | ||
| 426 | +} | ||
| 427 | + | ||
| 428 | +/// # Safety | ||
| 429 | +/// `key` is null or a NUL-terminated UTF-8 string. | ||
| 430 | +#[no_mangle] | ||
| 431 | +pub unsafe extern "C" fn vidya_node_get_bool(node: c_int, key: *const c_char) -> c_int { | ||
| 432 | + let key = borrowed_str(key); | ||
| 433 | + with_tree(0, |tree| { | ||
| 434 | + (match tree.get(node.max(0) as u32, &key) { | ||
| 435 | + Some(Value::Bool(b)) => *b, | ||
| 436 | + Some(Value::Num(n)) => *n != 0.0, | ||
| 437 | + _ => false, | ||
| 438 | + }) as c_int | ||
| 439 | + }) | ||
| 440 | +} | ||
| 441 | + | ||
| 442 | +/// The canonical tag name a node was created with; `hbox` and `vbox` both | ||
| 443 | +/// answer `box`. The empty string for a node that no longer exists. | ||
| 444 | +/// | ||
| 445 | +/// # Safety | ||
| 446 | +/// The returned pointer is valid until the next string-returning call on this | ||
| 447 | +/// thread. | ||
| 448 | +#[no_mangle] | ||
| 449 | +pub extern "C" fn vidya_node_tag(node: c_int) -> *const c_char { | ||
| 450 | + let tag = TREE.with_borrow(|tree| tree.tag_name(node.max(0) as u32).to_owned()); | ||
| 451 | + scratch(&tag) | ||
| 452 | +} | ||
| 453 | + | ||
| 454 | +/// The subtree at `node` as hiccup text, for logging and bug reports; `node` 0 | ||
| 455 | +/// means the root, so `vidya_tree_dump(0)` is the whole window. | ||
| 456 | +/// | ||
| 457 | +/// # Safety | ||
| 458 | +/// The returned pointer is valid until the next string-returning call on this | ||
| 459 | +/// thread. | ||
| 460 | +#[no_mangle] | ||
| 461 | +pub extern "C" fn vidya_tree_dump(node: c_int) -> *const c_char { | ||
| 462 | + let node = node.max(0) as u32; | ||
| 463 | + let text = TREE.with_borrow(|tree| { | ||
| 464 | + let id = if node == 0 { tree.root() } else { node }; | ||
| 465 | + tree.dump(id) | ||
| 466 | + }); | ||
| 467 | + scratch(&text) | ||
| 468 | +} | ||
| 469 | + | ||
| 470 | +#[no_mangle] | ||
| 471 | +pub extern "C" fn vidya_node_child_count(node: c_int) -> c_int { | ||
| 472 | + with_tree(0, |tree| tree.child_count(node.max(0) as u32) as c_int) | ||
| 473 | +} | ||
| 474 | + | ||
| 475 | +/// The `index`th child, or 0 when there is none. | ||
| 476 | +#[no_mangle] | ||
| 477 | +pub extern "C" fn vidya_node_child_at(node: c_int, index: c_int) -> c_int { | ||
| 478 | + with_tree(0, |tree| { | ||
| 479 | + tree.child_at(node.max(0) as u32, index.max(0) as usize) as c_int | ||
| 480 | + }) | ||
| 481 | +} | ||
| 482 | + | ||
| 483 | +#[no_mangle] | ||
| 484 | +pub extern "C" fn vidya_node_append(parent: c_int, child: c_int) -> c_int { | ||
| 485 | + with_tree(0, |tree| { | ||
| 486 | + tree.append(parent.max(0) as u32, child.max(0) as u32) as c_int | ||
| 487 | + }) | ||
| 488 | +} | ||
| 489 | + | ||
| 490 | +/// Unparent `child` **and free it**, with everything under it. | ||
| 491 | +/// | ||
| 492 | +/// glimmer says nothing further about a widget it has removed, so this is where | ||
| 493 | +/// a subtree's storage goes back. | ||
| 494 | +#[no_mangle] | ||
| 495 | +pub extern "C" fn vidya_node_remove(parent: c_int, child: c_int) { | ||
| 496 | + with_tree((), |tree| { | ||
| 497 | + tree.remove(parent.max(0) as u32, child.max(0) as u32) | ||
| 498 | + }); | ||
| 499 | +} | ||
| 500 | + | ||
| 501 | +/// Move `child` to sit immediately after `sibling`; `sibling` 0 means first. | ||
| 502 | +#[no_mangle] | ||
| 503 | +pub extern "C" fn vidya_node_insert_after(parent: c_int, child: c_int, sibling: c_int) -> c_int { | ||
| 504 | + with_tree(0, |tree| { | ||
| 505 | + tree.insert_after( | ||
| 506 | + parent.max(0) as u32, | ||
| 507 | + child.max(0) as u32, | ||
| 508 | + sibling.max(0) as u32, | ||
| 509 | + ) as c_int | ||
| 510 | + }) | ||
| 511 | +} | ||
| 512 | + | ||
| 513 | +/// Put `new_child` where `old_child` was, and free `old_child`. | ||
| 514 | +#[no_mangle] | ||
| 515 | +pub extern "C" fn vidya_node_replace(parent: c_int, old_child: c_int, new_child: c_int) -> c_int { | ||
| 516 | + with_tree(0, |tree| { | ||
| 517 | + tree.replace( | ||
| 518 | + parent.max(0) as u32, | ||
| 519 | + old_child.max(0) as u32, | ||
| 520 | + new_child.max(0) as u32, | ||
| 521 | + ) as c_int | ||
| 522 | + }) | ||
| 523 | +} | ||
| 524 | + | ||
| 525 | +/// Paint the whole tree as one frame: a `vidya_begin_frame`, the walk, and a | ||
| 526 | +/// `vidya_end_frame`. Inert with no window open. | ||
| 527 | +#[no_mangle] | ||
| 528 | +pub extern "C" fn vidya_tree_frame() { | ||
| 529 | + with_app((), |app| { | ||
| 530 | + app.begin_frame(); | ||
| 531 | + TREE.with_borrow_mut(|tree| { | ||
| 532 | + if let Some((ui, theme)) = app.ui() { | ||
| 533 | + tree.paint(ui, theme); | ||
| 534 | + } | ||
| 535 | + }); | ||
| 536 | + app.end_frame(); | ||
| 537 | + }); | ||
| 538 | +} | ||
| 539 | + | ||
| 540 | +/// Dequeue one event, returning 1 while there was one. Its fields are read with | ||
| 541 | +/// the accessors below, which describe the most recently dequeued event. | ||
| 542 | +#[no_mangle] | ||
| 543 | +pub extern "C" fn vidya_tree_poll_event() -> c_int { | ||
| 544 | + with_tree(0, |tree| tree.poll() as c_int) | ||
| 545 | +} | ||
| 546 | + | ||
| 547 | +#[no_mangle] | ||
| 548 | +pub extern "C" fn vidya_tree_event_node() -> c_int { | ||
| 549 | + with_tree(0, |tree| tree.current().map_or(0, |e| e.node) as c_int) | ||
| 550 | +} | ||
| 551 | + | ||
| 552 | +/// The event's name — `click`, `change`, `toggled`, `activate` — or the empty | ||
| 553 | +/// string when nothing has been dequeued. | ||
| 554 | +/// | ||
| 555 | +/// # Safety | ||
| 556 | +/// The returned pointer is valid until the next string-returning call on this | ||
| 557 | +/// thread. | ||
| 558 | +#[no_mangle] | ||
| 559 | +pub extern "C" fn vidya_tree_event_name() -> *const c_char { | ||
| 560 | + let name = TREE.with_borrow(|tree| tree.current().map_or("", |e| e.name).to_owned()); | ||
| 561 | + scratch(&name) | ||
| 562 | +} | ||
| 563 | + | ||
| 564 | +/// # Safety | ||
| 565 | +/// The returned pointer is valid until the next string-returning call on this | ||
| 566 | +/// thread. | ||
| 567 | +#[no_mangle] | ||
| 568 | +pub extern "C" fn vidya_tree_event_text() -> *const c_char { | ||
| 569 | + let text = TREE.with_borrow(|tree| tree.current().map_or(String::new(), |e| e.text.clone())); | ||
| 570 | + scratch(&text) | ||
| 571 | +} | ||
| 572 | + | ||
| 573 | +#[no_mangle] | ||
| 574 | +pub extern "C" fn vidya_tree_event_num() -> f64 { | ||
| 575 | + with_tree(0.0, |tree| tree.current().map_or(0.0, |e| e.num)) | ||
| 576 | +} | ||
| 577 | + | ||
| 578 | +// ── Live frames ───────────────────────────────────────────────────────────── | ||
| 579 | + | ||
| 580 | +/// Hand the tree a frame of raw pixels under `key`, painted by any `:image` | ||
| 581 | +/// whose `feed` prop names it. Answers 1 when the frame was accepted. | ||
| 582 | +/// | ||
| 583 | +/// This is the one thing an `:image` could not do: `src` decodes a file and | ||
| 584 | +/// caches the texture by its path forever, which is right for a picture in a | ||
| 585 | +/// message and useless for a source that produces a new picture thirty times a | ||
| 586 | +/// second. A caller that has its own pixels — a camera, a video decoder, a | ||
| 587 | +/// renderer — pushes them here instead, and the tag paints the latest. | ||
| 588 | +/// | ||
| 589 | +/// `rgba` is `width * height * 4` bytes, row-major, 8 bits a channel, | ||
| 590 | +/// un-premultiplied. It is copied before this returns, so the caller may reuse | ||
| 591 | +/// the buffer immediately; nothing on this side retains it. A length that | ||
| 592 | +/// disagrees with the dimensions is refused rather than painted torn. | ||
| 593 | +/// | ||
| 594 | +/// Frames are coalesced, not queued: one that arrives before the last has been | ||
| 595 | +/// painted replaces it. A source faster than the window costs no backlog. | ||
| 596 | +/// | ||
| 597 | +/// Like the rest of the tree ABI this must be called on the thread that opened | ||
| 598 | +/// the window — a frame produced on a decoder thread crosses to the UI thread | ||
| 599 | +/// on the caller's side, not this one. | ||
| 600 | +/// | ||
| 601 | +/// # Safety | ||
| 602 | +/// `key` is null or a NUL-terminated UTF-8 string; `rgba` is null or valid for | ||
| 603 | +/// reads of `width * height * 4` bytes for the duration of the call. | ||
| 604 | +#[no_mangle] | ||
| 605 | +pub unsafe extern "C" fn vidya_frame_rgba( | ||
| 606 | + key: *const c_char, | ||
| 607 | + width: c_int, | ||
| 608 | + height: c_int, | ||
| 609 | + rgba: *const u8, | ||
| 610 | +) -> c_int { | ||
| 611 | + let key = borrowed_str(key); | ||
| 612 | + if rgba.is_null() || width <= 0 || height <= 0 { | ||
| 613 | + return 0; | ||
| 614 | + } | ||
| 615 | + let len = (width as usize) | ||
| 616 | + .saturating_mul(height as usize) | ||
| 617 | + .saturating_mul(4); | ||
| 618 | + let pixels = std::slice::from_raw_parts(rgba, len); | ||
| 619 | + with_tree(0, |tree| { | ||
| 620 | + tree.set_frame(&key, width as u32, height as u32, pixels) as c_int | ||
| 621 | + }) | ||
| 622 | +} | ||
| 623 | + | ||
| 624 | +/// Forget the feed named `key` and release its texture, answering 1 when there | ||
| 625 | +/// was one. Without this the last frame of a source that has stopped keeps | ||
| 626 | +/// painting — the participant who left, still on the wall. | ||
| 627 | +/// | ||
| 628 | +/// # Safety | ||
| 629 | +/// `key` is null or a NUL-terminated UTF-8 string. | ||
| 630 | +#[no_mangle] | ||
| 631 | +pub unsafe extern "C" fn vidya_frame_drop(key: *const c_char) -> c_int { | ||
| 632 | + let key = borrowed_str(key); | ||
| 633 | + with_tree(0, |tree| tree.drop_frame(&key) as c_int) | ||
| 634 | +} | ||
| 635 | + | ||
| 636 | +// ── Clipboard ─────────────────────────────────────────────────────────────── | ||
| 637 | + | ||
| 638 | +/// Write the picture on the system clipboard to `path` as a PNG, answering 1 | ||
| 639 | +/// when there was one and it was written. | ||
| 640 | +/// | ||
| 641 | +/// egui carries clipboard *text* into the frame as an event and nothing else, | ||
| 642 | +/// so a pasted image has to be asked for rather than waited for: a caller | ||
| 643 | +/// binds this to whatever gesture means paste for it, and reads the file. | ||
| 644 | +/// PNG because that is what the `:image` node decodes. | ||
| 645 | +/// | ||
| 646 | +/// Unlike the rest of this ABI it needs no window and no particular thread — | ||
| 647 | +/// it talks to the platform clipboard, not to egui. | ||
| 648 | +/// | ||
| 649 | +/// # Safety | ||
| 650 | +/// `path` is null or a NUL-terminated UTF-8 string. | ||
| 651 | +#[no_mangle] | ||
| 652 | +pub unsafe extern "C" fn vidya_clipboard_image_png(path: *const c_char) -> c_int { | ||
| 653 | + let path = borrowed_str(path); | ||
| 654 | + guard(0, || { | ||
| 655 | + if path.is_empty() { | ||
| 656 | + return 0; | ||
| 657 | + } | ||
| 658 | + clipboard_image_png(&path) as c_int | ||
| 659 | + }) | ||
| 660 | +} | ||
| 661 | + | ||
| 662 | +#[cfg(not(target_os = "android"))] | ||
| 663 | +fn clipboard_image_png(path: &str) -> bool { | ||
| 664 | + let Ok(mut clipboard) = arboard::Clipboard::new() else { | ||
| 665 | + return false; | ||
| 666 | + }; | ||
| 667 | + // An empty clipboard, text on it, or a format the platform will not hand | ||
| 668 | + // over as pixels: all of them are "no picture to paste" to the caller. | ||
| 669 | + let Ok(image) = clipboard.get_image() else { | ||
| 670 | + return false; | ||
| 671 | + }; | ||
| 672 | + let Ok(file) = std::fs::File::create(path) else { | ||
| 673 | + return false; | ||
| 674 | + }; | ||
| 675 | + let mut encoder = png::Encoder::new( | ||
| 676 | + std::io::BufWriter::new(file), | ||
| 677 | + image.width as u32, | ||
| 678 | + image.height as u32, | ||
| 679 | + ); | ||
| 680 | + encoder.set_color(png::ColorType::Rgba); | ||
| 681 | + encoder.set_depth(png::BitDepth::Eight); | ||
| 682 | + let written = encoder | ||
| 683 | + .write_header() | ||
| 684 | + .and_then(|mut writer| writer.write_image_data(&image.bytes)) | ||
| 685 | + .is_ok(); | ||
| 686 | + // A half-written file is worse than none: the caller would upload it. | ||
| 687 | + if !written { | ||
| 688 | + let _ = std::fs::remove_file(path); | ||
| 689 | + } | ||
| 690 | + written | ||
| 691 | +} | ||
| 692 | + | ||
| 693 | +/// Android has no clipboard of images to read, and arboard no backend for it. | ||
| 694 | +#[cfg(target_os = "android")] | ||
| 695 | +fn clipboard_image_png(_path: &str) -> bool { | ||
| 696 | + false | ||
| 697 | +} | ||
| 698 | + | ||
| 699 | +/// Hand a URL to whatever shows web pages here; 1 when something took it. | ||
| 700 | +/// | ||
| 701 | +/// A sign-in flow leaves the app for a browser and comes back, so the app needs | ||
| 702 | +/// a way to say "open this". What that means is the platform's business, not | ||
| 703 | +/// the caller's: an `xdg-open`/`open` on the desktop, and on Android an | ||
| 704 | +/// ACTION_VIEW intent, which is a JNI call — a shelled-out `am start` is | ||
| 705 | +/// refused there, since `am` names `com.android.shell` as its calling package | ||
| 706 | +/// and that is not the app's uid. | ||
| 707 | +/// | ||
| 708 | +/// Like the clipboard call this needs no window and no particular thread. | ||
| 709 | +/// | ||
| 710 | +/// # Safety | ||
| 711 | +/// `url` is null or a NUL-terminated UTF-8 string. | ||
| 712 | +#[no_mangle] | ||
| 713 | +pub unsafe extern "C" fn vidya_open_url(url: *const c_char) -> c_int { | ||
| 714 | + let url = borrowed_str(url); | ||
| 715 | + guard(0, || { | ||
| 716 | + if url.is_empty() { | ||
| 717 | + return 0; | ||
| 718 | + } | ||
| 719 | + open_url(&url) as c_int | ||
| 720 | + }) | ||
| 721 | +} | ||
| 722 | + | ||
| 723 | +#[cfg(not(target_os = "android"))] | ||
| 724 | +fn open_url(url: &str) -> bool { | ||
| 725 | + let opener = if cfg!(target_os = "macos") { | ||
| 726 | + "open" | ||
| 727 | + } else { | ||
| 728 | + "xdg-open" | ||
| 729 | + }; | ||
| 730 | + // Spawned, not waited on: the browser outlives the call, and on some | ||
| 731 | + // desktops the opener itself stays in the foreground for as long as it | ||
| 732 | + // does. | ||
| 733 | + std::process::Command::new(opener) | ||
| 734 | + .arg(url) | ||
| 735 | + .stdout(std::process::Stdio::null()) | ||
| 736 | + .stderr(std::process::Stdio::null()) | ||
| 737 | + .spawn() | ||
| 738 | + .is_ok() | ||
| 739 | +} | ||
| 740 | + | ||
| 741 | +/// `startActivity(new Intent(ACTION_VIEW, Uri.parse(url)))` on the activity the | ||
| 742 | +/// glue holds. Any JNI failure — or a device with nothing that answers the | ||
| 743 | +/// intent — is a plain false; the caller shows the URL instead. | ||
| 744 | +#[cfg(target_os = "android")] | ||
| 745 | +fn open_url(url: &str) -> bool { | ||
| 746 | + use jni::objects::JObject; | ||
| 747 | + use jni::{jni_sig, jni_str}; | ||
| 748 | + | ||
| 749 | + let Some(app) = android::android_app() else { | ||
| 750 | + android::warn("open_url: no AndroidApp handle"); | ||
| 751 | + return false; | ||
| 752 | + }; | ||
| 753 | + // SAFETY: the glue owns both handles for the life of the activity, and | ||
| 754 | + // hands them out as raw pointers for exactly this. | ||
| 755 | + let vm = unsafe { jni::JavaVM::from_raw(app.vm_as_ptr().cast()) }; | ||
| 756 | + let activity_ptr = app.activity_as_ptr().cast(); | ||
| 757 | + | ||
| 758 | + let started: jni::errors::Result<()> = vm.attach_current_thread(|env| { | ||
| 759 | + // SAFETY: the activity outlives this frame, and the reference is a | ||
| 760 | + // borrow of the glue's own, not one this side owns. | ||
| 761 | + let activity = unsafe { JObject::from_raw(env, activity_ptr) }; | ||
| 762 | + let url = env.new_string(url)?; | ||
| 763 | + let uri = env | ||
| 764 | + .call_static_method( | ||
| 765 | + jni_str!("android/net/Uri"), | ||
| 766 | + jni_str!("parse"), | ||
| 767 | + jni_sig!("(Ljava/lang/String;)Landroid/net/Uri;"), | ||
| 768 | + &[(&url).into()], | ||
| 769 | + )? | ||
| 770 | + .l()?; | ||
| 771 | + let action = env.new_string("android.intent.action.VIEW")?; | ||
| 772 | + let intent = env.new_object( | ||
| 773 | + jni_str!("android/content/Intent"), | ||
| 774 | + jni_sig!("(Ljava/lang/String;Landroid/net/Uri;)V"), | ||
| 775 | + &[(&action).into(), (&uri).into()], | ||
| 776 | + )?; | ||
| 777 | + env.call_method( | ||
| 778 | + &activity, | ||
| 779 | + jni_str!("startActivity"), | ||
| 780 | + jni_sig!("(Landroid/content/Intent;)V"), | ||
| 781 | + &[(&intent).into()], | ||
| 782 | + )?; | ||
| 783 | + // An ActivityNotFoundException is thrown, not returned. Leaving it | ||
| 784 | + // pending would fail the next JNI call on this thread, whoever made | ||
| 785 | + // it, so it is caught here and read as "nothing took the URL". | ||
| 786 | + if env.exception_check() { | ||
| 787 | + env.exception_clear(); | ||
| 788 | + return Err(jni::errors::Error::JavaException); | ||
| 789 | + } | ||
| 790 | + Ok(()) | ||
| 791 | + }); | ||
| 792 | + | ||
| 793 | + if let Err(err) = &started { | ||
| 794 | + android::warn(&format!("vidya_open_url: {err}")); | ||
| 795 | + } | ||
| 796 | + started.is_ok() | ||
| 797 | +} | ||
added
crates/jolt-vidya/src/tree.rs +1600 -0 | new file mode 100644 | ||
| @@ -0,0 +1,1600 @@ | ||
| 1 | +//! A retained node tree, painted immediately. | |
| 2 | +//! | |
| 3 | +//! The push/pop half of this ABI (`vidya_card_begin` … `vidya_card_end`) suits | |
| 4 | +//! a caller that writes its UI out top to bottom every frame. A *reactive* | |
| 5 | +//! caller does not: glimmer keeps a component tree, reconciles it against new | |
| 6 | +//! hiccup, and emits create/patch/append/remove against whatever the toolkit | |
| 7 | +//! calls a widget. GTK has widgets to hand it; egui has none. | |
| 8 | +//! | |
| 9 | +//! So this module is the widget layer glimmer expects, on the Rust side of the | |
| 10 | +//! FFI. The caller gets integer node handles and mutates them — set a prop, | |
| 11 | +//! append a child, drop a subtree. Nothing is drawn by those calls. Once a | |
| 12 | +//! frame, [`Tree::paint`] walks the whole tree and emits the egui calls it | |
| 13 | +//! describes, and interactions come back out as a queue of events the caller | |
| 14 | +//! drains and routes to its own handlers. | |
| 15 | +//! | |
| 16 | +//! Two things fall out of that split that the push/pop ABI could not have: | |
| 17 | +//! | |
| 18 | +//! * **Closure-shaped egui APIs work.** `ScrollArea`, `Frame` and friends take | |
| 19 | +//! an `FnOnce(&mut Ui)` and keep their `begin`/`end` private, which is why | |
| 20 | +//! `vidya_page_begin` had to reimplement scrolling by hand and why the page | |
| 21 | +//! was documented as non-scrolling. Painting from a tree we already hold | |
| 22 | +//! means the recursion *is* the closure; nothing has to stay open across a | |
| 23 | +//! call boundary. | |
| 24 | +//! * **FFI traffic tracks edits, not frames.** A static UI at 60fps costs zero | |
| 25 | +//! crossings per frame; only what the reconciler actually changed is sent. | |
| 26 | +//! | |
| 27 | +//! The tree deliberately knows nothing about egui until [`Tree::paint`], so the | |
| 28 | +//! arena and its edit operations are unit-testable with no window. | |
| 29 | + | |
| 30 | +use std::collections::HashMap; | |
| 31 | +use std::collections::VecDeque; | |
| 32 | + | |
| 33 | +use egui::{Align, Align2, Color32, FontId, Id, Layout, Margin, TextureOptions, Ui, Vec2}; | |
| 34 | +use vidya_core::Theme; | |
| 35 | + | |
| 36 | +/// A prop value. The three types the ABI can carry, and all glimmer needs: | |
| 37 | +/// keywords and colours arrive as strings, numbers as doubles, flags as ints. | |
| 38 | +#[derive(Clone, Debug, PartialEq)] | |
| 39 | +pub enum Value { | |
| 40 | + Str(String), | |
| 41 | + Num(f64), | |
| 42 | + Bool(bool), | |
| 43 | +} | |
| 44 | + | |
| 45 | +/// What a node renders as. Unknown tags are kept rather than rejected: they | |
| 46 | +/// paint as a plain vertical box, so a caller using a tag this backend has not | |
| 47 | +/// grown yet still sees its children instead of nothing. | |
| 48 | +#[derive(Clone, Debug, PartialEq, Eq)] | |
| 49 | +pub enum Tag { | |
| 50 | + Window, | |
| 51 | + Box, | |
| 52 | + Page, | |
| 53 | + Card, | |
| 54 | + Frame, | |
| 55 | + Scroll, | |
| 56 | + Label, | |
| 57 | + Link, | |
| 58 | + Title, | |
| 59 | + Title2, | |
| 60 | + DimLabel, | |
| 61 | + Button, | |
| 62 | + CheckButton, | |
| 63 | + Entry, | |
| 64 | + Separator, | |
| 65 | + Spacer, | |
| 66 | + Progress, | |
| 67 | + Spinner, | |
| 68 | + Image, | |
| 69 | + Avatar, | |
| 70 | + Reaction, | |
| 71 | + Status, | |
| 72 | + /// A tag this backend has not grown yet, keeping the name it was created | |
| 73 | + /// with so a dump answers what the caller actually asked for. | |
| 74 | + Unknown(String), | |
| 75 | +} | |
| 76 | + | |
| 77 | +impl Tag { | |
| 78 | + /// Parse a hiccup tag name. `:hbox`/`:vbox` are the same box — the tag only | |
| 79 | + /// implies an orientation, which the caller sets as a prop. | |
| 80 | + fn parse(name: &str) -> Self { | |
| 81 | + match name { | |
| 82 | + "window" => Self::Window, | |
| 83 | + "box" | "hbox" | "vbox" => Self::Box, | |
| 84 | + "page" => Self::Page, | |
| 85 | + "card" => Self::Card, | |
| 86 | + "frame" => Self::Frame, | |
| 87 | + "scroll" => Self::Scroll, | |
| 88 | + "label" => Self::Label, | |
| 89 | + "link" => Self::Link, | |
| 90 | + "title" => Self::Title, | |
| 91 | + "title-2" => Self::Title2, | |
| 92 | + "dim-label" => Self::DimLabel, | |
| 93 | + "button" => Self::Button, | |
| 94 | + "checkbutton" | "checkbox" => Self::CheckButton, | |
| 95 | + "entry" => Self::Entry, | |
| 96 | + "separator" => Self::Separator, | |
| 97 | + "spacer" | "gap" => Self::Spacer, | |
| 98 | + "progress" => Self::Progress, | |
| 99 | + "spinner" => Self::Spinner, | |
| 100 | + "image" => Self::Image, | |
| 101 | + "avatar" => Self::Avatar, | |
| 102 | + "reaction" => Self::Reaction, | |
| 103 | + "status" => Self::Status, | |
| 104 | + other => Self::Unknown(other.to_owned()), | |
| 105 | + } | |
| 106 | + } | |
| 107 | + | |
| 108 | + /// The canonical name of a parsed tag: `:hbox` and `:vbox` both answer | |
| 109 | + /// `box`, since the orientation lives in a prop rather than in the tag. | |
| 110 | + fn name(&self) -> &str { | |
| 111 | + match self { | |
| 112 | + Self::Window => "window", | |
| 113 | + Self::Box => "box", | |
| 114 | + Self::Page => "page", | |
| 115 | + Self::Card => "card", | |
| 116 | + Self::Frame => "frame", | |
| 117 | + Self::Scroll => "scroll", | |
| 118 | + Self::Label => "label", | |
| 119 | + Self::Link => "link", | |
| 120 | + Self::Title => "title", | |
| 121 | + Self::Title2 => "title-2", | |
| 122 | + Self::DimLabel => "dim-label", | |
| 123 | + Self::Button => "button", | |
| 124 | + Self::CheckButton => "checkbutton", | |
| 125 | + Self::Entry => "entry", | |
| 126 | + Self::Separator => "separator", | |
| 127 | + Self::Spacer => "spacer", | |
| 128 | + Self::Progress => "progress", | |
| 129 | + Self::Spinner => "spinner", | |
| 130 | + Self::Image => "image", | |
| 131 | + Self::Avatar => "avatar", | |
| 132 | + Self::Reaction => "reaction", | |
| 133 | + Self::Status => "status", | |
| 134 | + Self::Unknown(name) => name, | |
| 135 | + } | |
| 136 | + } | |
| 137 | +} | |
| 138 | + | |
| 139 | +/// One interaction, waiting to be drained by the caller. | |
| 140 | +/// | |
| 141 | +/// Names match glimmer's handler props with the `on-` dropped: `click` pairs | |
| 142 | +/// with `:on-click`, `change` with `:on-change`, and so on. `text` and `num` | |
| 143 | +/// carry the payload the handler is called with, empty when it takes none. | |
| 144 | +#[derive(Clone, Debug, PartialEq)] | |
| 145 | +pub struct Event { | |
| 146 | + pub node: u32, | |
| 147 | + pub name: &'static str, | |
| 148 | + pub text: String, | |
| 149 | + pub num: f64, | |
| 150 | +} | |
| 151 | + | |
| 152 | +/// One prop value as EDN. Numbers that happen to be whole print without a | |
| 153 | +/// trailing `.0`, since every number crossed the boundary as a double and | |
| 154 | +/// `{:spacing 8.0}` reads worse than `{:spacing 8}`. | |
| 155 | +fn write_value(value: &Value, out: &mut String) { | |
| 156 | + match value { | |
| 157 | + Value::Bool(b) => out.push_str(if *b { "true" } else { "false" }), | |
| 158 | + Value::Num(n) => { | |
| 159 | + if n.is_finite() && n.fract() == 0.0 && n.abs() < 1e15 { | |
| 160 | + out.push_str(&format!("{}", *n as i64)); | |
| 161 | + } else if n.is_finite() { | |
| 162 | + out.push_str(&format!("{n}")); | |
| 163 | + } else { | |
| 164 | + // EDN has no infinity or NaN literal; say so rather than emit | |
| 165 | + // something no reader will take. | |
| 166 | + out.push_str("nil"); | |
| 167 | + } | |
| 168 | + } | |
| 169 | + Value::Str(text) => { | |
| 170 | + out.push('"'); | |
| 171 | + for c in text.chars() { | |
| 172 | + match c { | |
| 173 | + '"' => out.push_str("\\\""), | |
| 174 | + '\\' => out.push_str("\\\\"), | |
| 175 | + '\n' => out.push_str("\\n"), | |
| 176 | + '\r' => out.push_str("\\r"), | |
| 177 | + '\t' => out.push_str("\\t"), | |
| 178 | + _ => out.push(c), | |
| 179 | + } | |
| 180 | + } | |
| 181 | + out.push('"'); | |
| 182 | + } | |
| 183 | + } | |
| 184 | +} | |
| 185 | + | |
| 186 | +#[derive(Clone, Debug, Default)] | |
| 187 | +struct Node { | |
| 188 | + tag: Tag, | |
| 189 | + props: HashMap<String, Value>, | |
| 190 | + children: Vec<u32>, | |
| 191 | + /// 0 when unparented. The root's parent is 0 too, which is what stops the | |
| 192 | + /// ancestor walk in [`Tree::would_cycle`]. | |
| 193 | + parent: u32, | |
| 194 | +} | |
| 195 | + | |
| 196 | +impl Default for Tag { | |
| 197 | + fn default() -> Self { | |
| 198 | + Self::Unknown(String::new()) | |
| 199 | + } | |
| 200 | +} | |
| 201 | + | |
| 202 | +/// One named source of live pixels: what has arrived, and what is on the GPU. | |
| 203 | +#[derive(Default)] | |
| 204 | +struct Feed { | |
| 205 | + /// Pixels written since the last paint, waiting to be uploaded. Taken (not | |
| 206 | + /// copied) by the paint that consumes them. | |
| 207 | + pending: Option<egui::ColorImage>, | |
| 208 | + /// The texture the last upload produced. Kept when nothing new arrives, so | |
| 209 | + /// a still source keeps painting instead of blinking out between frames. | |
| 210 | + texture: Option<egui::TextureHandle>, | |
| 211 | +} | |
| 212 | + | |
| 213 | +/// The node arena. | |
| 214 | +/// | |
| 215 | +/// Handles are `index + 1`, so 0 is always "no node" — the value C gets back | |
| 216 | +/// from a failed allocation and the sibling argument that means "first". | |
| 217 | +/// Freed slots are reused, so a list that churns rows does not grow the arena. | |
| 218 | +pub struct Tree { | |
| 219 | + nodes: Vec<Option<Node>>, | |
| 220 | + free: Vec<u32>, | |
| 221 | + root: u32, | |
| 222 | + /// Decoded images, by the path they came from. An `:image` node is walked | |
| 223 | + /// every frame and must not decode a file every time. | |
| 224 | + textures: HashMap<String, Option<egui::TextureHandle>>, | |
| 225 | + /// Live pixels pushed in by name, for an `:image` with a `feed` rather | |
| 226 | + /// than a `src`. A caller that has frames of its own — a camera, a video | |
| 227 | + /// decoder, a renderer — writes them here and the tag paints the latest. | |
| 228 | + /// | |
| 229 | + /// Two halves, because the writer is not in a frame and the uploader is: | |
| 230 | + /// `pending` is what arrived since the last paint, `texture` is what was | |
| 231 | + /// uploaded from it. A frame that arrives twice between paints overwrites | |
| 232 | + /// the first, so a 30fps source cannot outrun a 60fps window into a queue. | |
| 233 | + feeds: HashMap<String, Feed>, | |
| 234 | + pending: VecDeque<Event>, | |
| 235 | + /// The event most recently dequeued by `poll`, whose fields the accessors | |
| 236 | + /// read. Held here so the ABI can return a payload without out-parameters. | |
| 237 | + current: Option<Event>, | |
| 238 | +} | |
| 239 | + | |
| 240 | +/// A stable colour for a name: the same person is the same colour every time, | |
| 241 | +/// and two people are unlikely to share one. Kept dark enough for the light | |
| 242 | +/// text drawn on top and dull enough not to compete with the accent. | |
| 243 | +fn name_colour(name: &str, theme: &Theme) -> Color32 { | |
| 244 | + let mut hash: u32 = 2166136261; | |
| 245 | + for b in name.as_bytes() { | |
| 246 | + hash ^= *b as u32; | |
| 247 | + hash = hash.wrapping_mul(16777619); | |
| 248 | + } | |
| 249 | + // Six hues around the wheel, at a fixed saturation and value, rather than | |
| 250 | + // free RGB: random channels give muddy colours as often as good ones. | |
| 251 | + let sector = (hash % 6) as f32; | |
| 252 | + let (r, g, b) = match sector as u32 { | |
| 253 | + 0 => (0.80, 0.35, 0.35), | |
| 254 | + 1 => (0.80, 0.55, 0.25), | |
| 255 | + 2 => (0.45, 0.65, 0.35), | |
| 256 | + 3 => (0.30, 0.60, 0.65), | |
| 257 | + 4 => (0.40, 0.50, 0.80), | |
| 258 | + _ => (0.65, 0.40, 0.70), | |
| 259 | + }; | |
| 260 | + let _ = theme; | |
| 261 | + Color32::from_rgb((r * 255.0) as u8, (g * 255.0) as u8, (b * 255.0) as u8) | |
| 262 | +} | |
| 263 | + | |
| 264 | +impl Tree { | |
| 265 | + /// Whether anything under `id` has `:scroll-here` set this frame. | |
| 266 | + /// | |
| 267 | + /// Walked rather than remembered: the prop is set for the moment of a jump | |
| 268 | + /// and taken off again, so there is nothing to keep, and this runs once | |
| 269 | + /// per scroll area rather than once per node. | |
| 270 | + fn wants_scroll_to(&self, id: u32) -> bool { | |
| 271 | + let Some(node) = self.slot(id) else { | |
| 272 | + return false; | |
| 273 | + }; | |
| 274 | + matches!(node.props.get("scroll-here"), Some(Value::Bool(true))) | |
| 275 | + || node | |
| 276 | + .children | |
| 277 | + .iter() | |
| 278 | + .any(|child| self.wants_scroll_to(*child)) | |
| 279 | + } | |
| 280 | + | |
| 281 | + /// The texture for a file, decoding it the first time it is asked for. | |
| 282 | + /// A file that will not decode is remembered as such, so a bad path costs | |
| 283 | + /// one failed read rather than one per frame. | |
| 284 | + /// Hand the tree a frame of live pixels under `key`, to be painted by any | |
| 285 | + /// `:image` whose `feed` names it. `rgba` is `width * height * 4` bytes, | |
| 286 | + /// row-major, and is copied — the caller keeps ownership and may reuse the | |
| 287 | + /// buffer the moment this returns. | |
| 288 | + /// | |
| 289 | + /// Rejects a frame whose length disagrees with its dimensions rather than | |
| 290 | + /// painting torn pixels: a capture path that changes resolution mid-stream | |
| 291 | + /// otherwise reads the tail of the old buffer as the head of the new one. | |
| 292 | + pub fn set_frame(&mut self, key: &str, width: u32, height: u32, rgba: &[u8]) -> bool { | |
| 293 | + if key.is_empty() || width == 0 || height == 0 { | |
| 294 | + return false; | |
| 295 | + } | |
| 296 | + let expected = (width as usize) | |
| 297 | + .saturating_mul(height as usize) | |
| 298 | + .saturating_mul(4); | |
| 299 | + if rgba.len() != expected { | |
| 300 | + return false; | |
| 301 | + } | |
| 302 | + let image = egui::ColorImage::from_rgba_unmultiplied( | |
| 303 | + [width as usize, height as usize], | |
| 304 | + rgba, | |
| 305 | + ); | |
| 306 | + // Overwrites whatever had not been painted yet: the newest frame is | |
| 307 | + // the only one worth showing, and a backlog of stale ones is latency. | |
| 308 | + self.feeds.entry(key.to_owned()).or_default().pending = Some(image); | |
| 309 | + true | |
| 310 | + } | |
| 311 | + | |
| 312 | + /// Forget a feed and release its texture. A call that ends leaves a tile | |
| 313 | + /// behind otherwise — the last frame of a participant who has gone. | |
| 314 | + pub fn drop_frame(&mut self, key: &str) -> bool { | |
| 315 | + self.feeds.remove(key).is_some() | |
| 316 | + } | |
| 317 | + | |
| 318 | + /// The texture for a feed, uploading this paint's pending frame first. | |
| 319 | + fn feed_texture(&mut self, ui: &Ui, key: &str) -> Option<egui::TextureHandle> { | |
| 320 | + let feed = self.feeds.get_mut(key)?; | |
| 321 | + if let Some(image) = feed.pending.take() { | |
| 322 | + match feed.texture.as_mut() { | |
| 323 | + // `set` reuses the allocation when the size is unchanged, | |
| 324 | + // which is the whole point at video rates. | |
| 325 | + Some(texture) => texture.set(image, TextureOptions::LINEAR), | |
| 326 | + None => { | |
| 327 | + feed.texture = Some(ui.ctx().load_texture( | |
| 328 | + format!("vidya/tree/feed/{key}"), | |
| 329 | + image, | |
| 330 | + TextureOptions::LINEAR, | |
| 331 | + )) | |
| 332 | + } | |
| 333 | + } | |
| 334 | + } | |
| 335 | + feed.texture.clone() | |
| 336 | + } | |
| 337 | + | |
| 338 | + fn texture(&mut self, ui: &Ui, path: &str) -> Option<egui::TextureHandle> { | |
| 339 | + if let Some(cached) = self.textures.get(path) { | |
| 340 | + return cached.clone(); | |
| 341 | + } | |
| 342 | + let handle = std::fs::read(path) | |
| 343 | + .ok() | |
| 344 | + .and_then(|bytes| decode_png_rgba(&bytes)) | |
| 345 | + .map(|image| { | |
| 346 | + ui.ctx() | |
| 347 | + .load_texture(format!("vidya/tree/{path}"), image, TextureOptions::LINEAR) | |
| 348 | + }); | |
| 349 | + self.textures.insert(path.to_owned(), handle.clone()); | |
| 350 | + handle | |
| 351 | + } | |
| 352 | +} | |
| 353 | + | |
| 354 | +/// PNG bytes as an egui image. PNG alone: it is what the vendored decoder | |
| 355 | +/// reads, and what the media this paints is served as. | |
| 356 | +fn decode_png_rgba(bytes: &[u8]) -> Option<egui::ColorImage> { | |
| 357 | + let mut decoder = png::Decoder::new(std::io::Cursor::new(bytes)); | |
| 358 | + decoder.set_transformations(png::Transformations::EXPAND | png::Transformations::ALPHA); | |
| 359 | + let mut reader = decoder.read_info().ok()?; | |
| 360 | + let mut buf = vec![0; reader.output_buffer_size()]; | |
| 361 | + let info = reader.next_frame(&mut buf).ok()?; | |
| 362 | + let (w, h) = (info.width as usize, info.height as usize); | |
| 363 | + let raw = &buf[..info.buffer_size()]; | |
| 364 | + let rgba: Vec<u8> = match info.color_type { | |
| 365 | + png::ColorType::Rgba => raw.to_vec(), | |
| 366 | + png::ColorType::Rgb => raw | |
| 367 | + .chunks_exact(3) | |
| 368 | + .flat_map(|c| [c[0], c[1], c[2], 255]) | |
| 369 | + .collect(), | |
| 370 | + _ => return None, | |
| 371 | + }; | |
| 372 | + (rgba.len() == w * h * 4).then(|| egui::ColorImage::from_rgba_unmultiplied([w, h], &rgba)) | |
| 373 | +} | |
| 374 | + | |
| 375 | +impl Default for Tree { | |
| 376 | + fn default() -> Self { | |
| 377 | + let mut tree = Self { | |
| 378 | + nodes: Vec::new(), | |
| 379 | + free: Vec::new(), | |
| 380 | + root: 0, | |
| 381 | + textures: HashMap::new(), | |
| 382 | + feeds: HashMap::new(), | |
| 383 | + pending: VecDeque::new(), | |
| 384 | + current: None, | |
| 385 | + }; | |
| 386 | + tree.root = tree.new_node("window"); | |
| 387 | + tree | |
| 388 | + } | |
| 389 | +} | |
| 390 | + | |
| 391 | +impl Tree { | |
| 392 | + pub fn root(&self) -> u32 { | |
| 393 | + self.root | |
| 394 | + } | |
| 395 | + | |
| 396 | + fn slot(&self, id: u32) -> Option<&Node> { | |
| 397 | + if id == 0 { | |
| 398 | + return None; | |
| 399 | + } | |
| 400 | + self.nodes.get(id as usize - 1).and_then(Option::as_ref) | |
| 401 | + } | |
| 402 | + | |
| 403 | + fn slot_mut(&mut self, id: u32) -> Option<&mut Node> { | |
| 404 | + if id == 0 { | |
| 405 | + return None; | |
| 406 | + } | |
| 407 | + self.nodes.get_mut(id as usize - 1).and_then(Option::as_mut) | |
| 408 | + } | |
| 409 | + | |
| 410 | + pub fn exists(&self, id: u32) -> bool { | |
| 411 | + self.slot(id).is_some() | |
| 412 | + } | |
| 413 | + | |
| 414 | + // ── editing ───────────────────────────────────────────────────────────── | |
| 415 | + | |
| 416 | + pub fn new_node(&mut self, tag: &str) -> u32 { | |
| 417 | + let node = Node { | |
| 418 | + tag: Tag::parse(tag), | |
| 419 | + ..Node::default() | |
| 420 | + }; | |
| 421 | + match self.free.pop() { | |
| 422 | + Some(id) => { | |
| 423 | + self.nodes[id as usize - 1] = Some(node); | |
| 424 | + id | |
| 425 | + } | |
| 426 | + None => { | |
| 427 | + self.nodes.push(Some(node)); | |
| 428 | + self.nodes.len() as u32 | |
| 429 | + } | |
| 430 | + } | |
| 431 | + } | |
| 432 | + | |
| 433 | + /// Drop `id` and everything under it, unparenting it first. | |
| 434 | + /// | |
| 435 | + /// glimmer has no separate destroy operation — `remove-child!` is the last | |
| 436 | + /// the reconciler ever says about a widget — so removal frees, and a node | |
| 437 | + /// handle the caller still holds after that is simply dead. | |
| 438 | + pub fn free_node(&mut self, id: u32) { | |
| 439 | + let parent = match self.slot(id) { | |
| 440 | + Some(n) => n.parent, | |
| 441 | + None => return, | |
| 442 | + }; | |
| 443 | + self.detach(parent, id); | |
| 444 | + self.free_subtree(id); | |
| 445 | + } | |
| 446 | + | |
| 447 | + fn free_subtree(&mut self, id: u32) { | |
| 448 | + let Some(node) = self.slot_mut(id).map(std::mem::take) else { | |
| 449 | + return; | |
| 450 | + }; | |
| 451 | + self.nodes[id as usize - 1] = None; | |
| 452 | + self.free.push(id); | |
| 453 | + for child in node.children { | |
| 454 | + self.free_subtree(child); | |
| 455 | + } | |
| 456 | + // An event queued against a node that has since been removed would be | |
| 457 | + // routed to a handler the caller has already forgotten. | |
| 458 | + self.pending.retain(|e| e.node != id); | |
| 459 | + } | |
| 460 | + | |
| 461 | + /// Unparent `child` without freeing it. `parent` may be 0 (already loose). | |
| 462 | + fn detach(&mut self, parent: u32, child: u32) { | |
| 463 | + if let Some(p) = self.slot_mut(parent) { | |
| 464 | + p.children.retain(|&c| c != child); | |
| 465 | + } | |
| 466 | + if let Some(c) = self.slot_mut(child) { | |
| 467 | + c.parent = 0; | |
| 468 | + } | |
| 469 | + } | |
| 470 | + | |
| 471 | + /// True when parenting `child` under `parent` would make a loop — `child` | |
| 472 | + /// is `parent`, or an ancestor of it. A cycle here is an infinite paint, | |
| 473 | + /// so it is checked rather than trusted. | |
| 474 | + fn would_cycle(&self, parent: u32, child: u32) -> bool { | |
| 475 | + let mut at = parent; | |
| 476 | + while at != 0 { | |
| 477 | + if at == child { | |
| 478 | + return true; | |
| 479 | + } | |
| 480 | + at = match self.slot(at) { | |
| 481 | + Some(n) => n.parent, | |
| 482 | + None => 0, | |
| 483 | + }; | |
| 484 | + } | |
| 485 | + false | |
| 486 | + } | |
| 487 | + | |
| 488 | + pub fn append(&mut self, parent: u32, child: u32) -> bool { | |
| 489 | + self.insert_at(parent, child, usize::MAX) | |
| 490 | + } | |
| 491 | + | |
| 492 | + fn insert_at(&mut self, parent: u32, child: u32, index: usize) -> bool { | |
| 493 | + if parent == 0 || child == 0 || !self.exists(parent) || !self.exists(child) { | |
| 494 | + return false; | |
| 495 | + } | |
| 496 | + if self.would_cycle(parent, child) { | |
| 497 | + return false; | |
| 498 | + } | |
| 499 | + // Moving a child that already has a parent (including this one) is a | |
| 500 | + // reparent, not a duplicate: take it out first so it appears once. | |
| 501 | + let old_parent = self.slot(child).map_or(0, |n| n.parent); | |
| 502 | + self.detach(old_parent, child); | |
| 503 | + | |
| 504 | + let p = self.slot_mut(parent).expect("checked above"); | |
| 505 | + let at = index.min(p.children.len()); | |
| 506 | + p.children.insert(at, child); | |
| 507 | + self.slot_mut(child).expect("checked above").parent = parent; | |
| 508 | + true | |
| 509 | + } | |
| 510 | + | |
| 511 | + pub fn remove(&mut self, parent: u32, child: u32) { | |
| 512 | + if self.slot(child).map_or(true, |n| n.parent != parent) { | |
| 513 | + return; | |
| 514 | + } | |
| 515 | + self.free_node(child); | |
| 516 | + } | |
| 517 | + | |
| 518 | + /// Move `child` to sit immediately after `sibling`; `sibling` 0 means first. | |
| 519 | + /// glimmer's keyed reconciliation calls this to reorder a list without | |
| 520 | + /// rebuilding the widgets in it. | |
| 521 | + pub fn insert_after(&mut self, parent: u32, child: u32, sibling: u32) -> bool { | |
| 522 | + if !self.exists(parent) || !self.exists(child) { | |
| 523 | + return false; | |
| 524 | + } | |
| 525 | + let index = if sibling == 0 { | |
| 526 | + 0 | |
| 527 | + } else { | |
| 528 | + match self | |
| 529 | + .slot(parent) | |
| 530 | + .and_then(|p| p.children.iter().position(|&c| c == sibling)) | |
| 531 | + { | |
| 532 | + // The sibling's own slot, once `child` is out of the way, is | |
| 533 | + // the position after it. | |
| 534 | + Some(i) => i + 1, | |
| 535 | + None => return false, | |
| 536 | + } | |
| 537 | + }; | |
| 538 | + // Re-derive the index after detaching: removing `child` from earlier in | |
| 539 | + // the list shifts everything after it down one. | |
| 540 | + let before = self | |
| 541 | + .slot(parent) | |
| 542 | + .and_then(|p| p.children.iter().position(|&c| c == child)) | |
| 543 | + .map_or(false, |i| i < index); | |
| 544 | + self.insert_at(parent, child, if before { index - 1 } else { index }) | |
| 545 | + } | |
| 546 | + | |
| 547 | + pub fn replace(&mut self, parent: u32, old: u32, new: u32) -> bool { | |
| 548 | + let Some(index) = self | |
| 549 | + .slot(parent) | |
| 550 | + .and_then(|p| p.children.iter().position(|&c| c == old)) | |
| 551 | + else { | |
| 552 | + return false; | |
| 553 | + }; | |
| 554 | + if !self.insert_at(parent, new, index) { | |
| 555 | + return false; | |
| 556 | + } | |
| 557 | + self.remove(parent, old); | |
| 558 | + true | |
| 559 | + } | |
| 560 | + | |
| 561 | + /// The canonical tag name, or the empty string for a node that is not | |
| 562 | + /// there. With [`Tree::child_count`] and [`Tree::child_at`] this is enough | |
| 563 | + /// for a caller to read back the tree it built — which is how the jolt | |
| 564 | + /// backend's tests assert against a real reconcile with no window open. | |
| 565 | + pub fn tag_name(&self, id: u32) -> &str { | |
| 566 | + self.slot(id).map_or("", |n| n.tag.name()) | |
| 567 | + } | |
| 568 | + | |
| 569 | + pub fn child_count(&self, id: u32) -> usize { | |
| 570 | + self.slot(id).map_or(0, |n| n.children.len()) | |
| 571 | + } | |
| 572 | + | |
| 573 | + pub fn child_at(&self, id: u32, index: usize) -> u32 { | |
| 574 | + self.slot(id) | |
| 575 | + .and_then(|n| n.children.get(index)) | |
| 576 | + .copied() | |
| 577 | + .unwrap_or(0) | |
| 578 | + } | |
| 579 | + | |
| 580 | + // ── props ─────────────────────────────────────────────────────────────── | |
| 581 | + | |
| 582 | + pub fn set(&mut self, id: u32, key: &str, value: Value) { | |
| 583 | + if let Some(node) = self.slot_mut(id) { | |
| 584 | + node.props.insert(key.to_owned(), value); | |
| 585 | + } | |
| 586 | + } | |
| 587 | + | |
| 588 | + pub fn clear_props(&mut self, id: u32) { | |
| 589 | + if let Some(node) = self.slot_mut(id) { | |
| 590 | + node.props.clear(); | |
| 591 | + } | |
| 592 | + } | |
| 593 | + | |
| 594 | + pub fn get(&self, id: u32, key: &str) -> Option<&Value> { | |
| 595 | + self.slot(id).and_then(|n| n.props.get(key)) | |
| 596 | + } | |
| 597 | + | |
| 598 | + // ── reading it back as hiccup ─────────────────────────────────────────── | |
| 599 | + | |
| 600 | + /// The subtree at `id` as pretty-printed hiccup, in the same shape the | |
| 601 | + /// caller wrote: `[:tag {props} children…]`, one node to a line. | |
| 602 | + /// | |
| 603 | + /// This is what the tree *is*, not what a component said — it is read from | |
| 604 | + /// the arena after the reconciler has had its way with it, so a patch that | |
| 605 | + /// went to the wrong node shows up here as a difference from the source. | |
| 606 | + /// | |
| 607 | + /// A node that does not exist dumps as `nil`. `:hbox` and `:vbox` both | |
| 608 | + /// dump as `:box`, as they are both stored as one; their orientation is in | |
| 609 | + /// the props. Handlers are not here — they never crossed the boundary. | |
| 610 | + pub fn dump(&self, id: u32) -> String { | |
| 611 | + let mut out = String::new(); | |
| 612 | + self.dump_into(id, 0, &mut out); | |
| 613 | + out | |
| 614 | + } | |
| 615 | + | |
| 616 | + fn dump_into(&self, id: u32, depth: usize, out: &mut String) { | |
| 617 | + let Some(node) = self.slot(id) else { | |
| 618 | + out.push_str("nil"); | |
| 619 | + return; | |
| 620 | + }; | |
| 621 | + let indent = " ".repeat(depth); | |
| 622 | + out.push_str("[:"); | |
| 623 | + out.push_str(node.tag.name()); | |
| 624 | + | |
| 625 | + // Sorted, so two dumps of the same tree compare as text. | |
| 626 | + let mut keys: Vec<&String> = node.props.keys().collect(); | |
| 627 | + keys.sort(); | |
| 628 | + out.push_str(" {"); | |
| 629 | + for (i, key) in keys.iter().enumerate() { | |
| 630 | + if i > 0 { | |
| 631 | + out.push(' '); | |
| 632 | + } | |
| 633 | + out.push(':'); | |
| 634 | + out.push_str(key); | |
| 635 | + out.push(' '); | |
| 636 | + write_value(&node.props[*key], out); | |
| 637 | + } | |
| 638 | + out.push('}'); | |
| 639 | + | |
| 640 | + for child in &node.children { | |
| 641 | + out.push('\n'); | |
| 642 | + out.push_str(&indent); | |
| 643 | + out.push_str(" "); | |
| 644 | + self.dump_into(*child, depth + 1, out); | |
| 645 | + } | |
| 646 | + out.push(']'); | |
| 647 | + } | |
| 648 | + | |
| 649 | + // ── events ────────────────────────────────────────────────────────────── | |
| 650 | + | |
| 651 | + fn emit(&mut self, node: u32, name: &'static str, text: String, num: f64) { | |
| 652 | + self.pending.push_back(Event { | |
| 653 | + node, | |
| 654 | + name, | |
| 655 | + text, | |
| 656 | + num, | |
| 657 | + }); | |
| 658 | + } | |
| 659 | + | |
| 660 | + /// Dequeue one event into the accessor slot. False when the queue is empty. | |
| 661 | + pub fn poll(&mut self) -> bool { | |
| 662 | + self.current = self.pending.pop_front(); | |
| 663 | + self.current.is_some() | |
| 664 | + } | |
| 665 | + | |
| 666 | + pub fn current(&self) -> Option<&Event> { | |
| 667 | + self.current.as_ref() | |
| 668 | + } | |
| 669 | + | |
| 670 | + // ── painting ──────────────────────────────────────────────────────────── | |
| 671 | + | |
| 672 | + /// Emit the whole tree into `ui`. Called once per frame. | |
| 673 | + pub fn paint(&mut self, ui: &mut Ui, theme: &Theme) { | |
| 674 | + let root = self.root; | |
| 675 | + self.paint_node(root, ui, theme); | |
| 676 | + } | |
| 677 | + | |
| 678 | + fn paint_children(&mut self, id: u32, ui: &mut Ui, theme: &Theme) { | |
| 679 | + // The child list is copied rather than borrowed: painting a child can | |
| 680 | + // write a prop back (an entry's text) or queue an event, both of which | |
| 681 | + // need `&mut self` while the walk is in flight. A UI's worth of `u32`s | |
| 682 | + // is a cheap price for not threading a cell through every widget. | |
| 683 | + let children = self | |
| 684 | + .slot(id) | |
| 685 | + .map(|n| n.children.clone()) | |
| 686 | + .unwrap_or_default(); | |
| 687 | + for child in children { | |
| 688 | + self.paint_node(child, ui, theme); | |
| 689 | + } | |
| 690 | + } | |
| 691 | + | |
| 692 | + fn paint_node(&mut self, id: u32, ui: &mut Ui, theme: &Theme) { | |
| 693 | + let Some((tag, props)) = self | |
| 694 | + .slot(id) | |
| 695 | + .map(|n| (n.tag.clone(), Props(n.props.clone()))) | |
| 696 | + else { | |
| 697 | + return; | |
| 698 | + }; | |
| 699 | + let enabled = props.bool("sensitive", true); | |
| 700 | + // `:scroll-here` brings this node into view in whatever scroll area it | |
| 701 | + // sits in. It fires on every frame the prop is set, so a caller sets it | |
| 702 | + // for the moment of a jump and takes it off again — leaving it on would | |
| 703 | + // pin the area there and take scrolling away from the reader. | |
| 704 | + let scroll_here = props.bool("scroll-here", false); | |
| 705 | + let before = ui.cursor().top(); | |
| 706 | + self.with_width(&props, ui, |tree, ui| { | |
| 707 | + if enabled { | |
| 708 | + tree.paint_tag(id, &tag, &props, ui, theme); | |
| 709 | + } else { | |
| 710 | + // Scoped rather than per-widget: a dimmed container dims its | |
| 711 | + // whole subtree, which is what `:sensitive false` means | |
| 712 | + // everywhere else in glimmer. | |
| 713 | + ui.add_enabled_ui(false, |ui| tree.paint_tag(id, &tag, &props, ui, theme)); | |
| 714 | + } | |
| 715 | + }); | |
| 716 | + if scroll_here { | |
| 717 | + // Horizontally the rect is the visible width, not the node's own: | |
| 718 | + // a rect wider than the viewport is off-screen sideways as far as | |
| 719 | + // egui is concerned, so it scrolls across to centre it and the | |
| 720 | + // reader lands on a message with its left edge cut off. Already | |
| 721 | + // visible on that axis means only the vertical scroll happens. | |
| 722 | + let clip = ui.clip_rect(); | |
| 723 | + let rect = egui::Rect::from_min_max( | |
| 724 | + egui::pos2(clip.left(), before), | |
| 725 | + egui::pos2(clip.right(), ui.cursor().top()), | |
| 726 | + ); | |
| 727 | + ui.scroll_to_rect(rect, Some(Align::Center)); | |
| 728 | + } | |
| 729 | + } | |
| 730 | + | |
| 731 | + /// Constrain `add` to the node's `:width-request`, when it has one. | |
| 732 | + /// | |
| 733 | + /// Immediate mode has no natural width for a field: an entry asks for | |
| 734 | + /// whatever is left, so an entry beside a button in an `:hbox` takes the | |
| 735 | + /// row and wraps the button onto the next line. This is how a caller says | |
| 736 | + /// otherwise. | |
| 737 | + fn with_width(&mut self, props: &Props, ui: &mut Ui, add: impl FnOnce(&mut Self, &mut Ui)) { | |
| 738 | + let requested = props.num("width-request", 0.0) as f32; | |
| 739 | + let fill_height = props.bool("fill-height", false); | |
| 740 | + if requested <= 0.0 && !fill_height { | |
| 741 | + add(self, ui); | |
| 742 | + return; | |
| 743 | + } | |
| 744 | + let avail = ui.available_width().max(1.0); | |
| 745 | + let width = if requested > 0.0 { | |
| 746 | + requested.min(avail) | |
| 747 | + } else { | |
| 748 | + avail | |
| 749 | + }; | |
| 750 | + // The height is the row's, not zero: a region allocated with no height | |
| 751 | + // leaves the row measuring nothing at the moment the next widget is | |
| 752 | + // placed, so a button beside a text field lands at the row's top edge | |
| 753 | + // instead of beside it. | |
| 754 | + // | |
| 755 | + // A column of a split is the other case. Inside a row, "what is left" | |
| 756 | + // is the row's own height — one button tall at the moment the column | |
| 757 | + // is placed — so a pane asking for it is allocated a strip, and the | |
| 758 | + // scrolling list inside it gets no room. `:fill-height` measures | |
| 759 | + // against what is visible below the cursor instead, the way `:scroll` | |
| 760 | + // does: everything from here to the bottom of the window. | |
| 761 | + let height = if fill_height { | |
| 762 | + (ui.clip_rect().bottom() - ui.cursor().top()).max(0.0) | |
| 763 | + } else { | |
| 764 | + ui.available_height().max(0.0) | |
| 765 | + }; | |
| 766 | + ui.allocate_ui_with_layout( | |
| 767 | + Vec2::new(width, height), | |
| 768 | + Layout::top_down(Align::Min), | |
| 769 | + |ui| { | |
| 770 | + ui.set_min_width(width); | |
| 771 | + ui.set_max_width(width); | |
| 772 | + add(self, ui); | |
| 773 | + }, | |
| 774 | + ); | |
| 775 | + } | |
| 776 | + | |
| 777 | + fn paint_tag(&mut self, id: u32, tag: &Tag, props: &Props, ui: &mut Ui, theme: &Theme) { | |
| 778 | + match tag { | |
| 779 | + // The root is the window itself: its children stack down the page. | |
| 780 | + // | |
| 781 | + // Its width is written back onto it, the way an entry writes back | |
| 782 | + // its text: a caller laying out against the window — one pane on a | |
| 783 | + // phone, two side by side on a desktop — has no other way to ask | |
| 784 | + // how much room it has, since nothing else here measures. | |
| 785 | + Tag::Window => { | |
| 786 | + let width = ui.available_width().max(0.0) as f64; | |
| 787 | + self.set(id, "window-width", Value::Num(width)); | |
| 788 | + self.paint_children(id, ui, theme) | |
| 789 | + } | |
| 790 | + | |
| 791 | + Tag::Box | Tag::Unknown(_) => { | |
| 792 | + let horizontal = props.str("orientation") == "horizontal"; | |
| 793 | + let spacing = props.num("spacing", theme.spacing.sm as f64) as f32; | |
| 794 | + self.with_margin(props, ui, |tree, ui| { | |
| 795 | + let axis = if horizontal { | |
| 796 | + Vec2::new(spacing, ui.spacing().item_spacing.y) | |
| 797 | + } else { | |
| 798 | + Vec2::new(ui.spacing().item_spacing.x, spacing) | |
| 799 | + }; | |
| 800 | + if horizontal { | |
| 801 | + // `:align :end` lays the row out from the right edge of | |
| 802 | + // the space it is given, which is how a trailing group | |
| 803 | + // — an action beside a message, a count beside a name — | |
| 804 | + // sits against the right of a row rather than trailing | |
| 805 | + // whatever came before it. | |
| 806 | + if props.str("align") == "end" { | |
| 807 | + // Nested in a row of its own: a right-to-left | |
| 808 | + // layout takes the height available to it, which | |
| 809 | + // in a column is everything below — every such row | |
| 810 | + // would be as tall as the rest of the screen, and | |
| 811 | + // the gaps would land between the rows above it. | |
| 812 | + ui.horizontal(|ui| { | |
| 813 | + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { | |
| 814 | + ui.spacing_mut().item_spacing = axis; | |
| 815 | + tree.paint_children(id, ui, theme); | |
| 816 | + }); | |
| 817 | + }); | |
| 818 | + } else { | |
| 819 | + ui.horizontal_wrapped(|ui| { | |
| 820 | + ui.spacing_mut().item_spacing = axis; | |
| 821 | + tree.paint_children(id, ui, theme); | |
| 822 | + }); | |
| 823 | + } | |
| 824 | + } else { | |
| 825 | + // `:align :center` puts a column's children on the | |
| 826 | + // middle of the width rather than against its left | |
| 827 | + // edge — what a picture on a screen of its own wants, | |
| 828 | + // and nothing a column of text ever does. | |
| 829 | + let cross = if props.str("align") == "center" { | |
| 830 | + Align::Center | |
| 831 | + } else { | |
| 832 | + Align::Min | |
| 833 | + }; | |
| 834 | + ui.with_layout(Layout::top_down(cross), |ui| { | |
| 835 | + ui.spacing_mut().item_spacing = axis; | |
| 836 | + tree.paint_children(id, ui, theme); | |
| 837 | + }); | |
| 838 | + } | |
| 839 | + }); | |
| 840 | + } | |
| 841 | + | |
| 842 | + // A scrolling column with page padding, optionally centred at a | |
| 843 | + // maximum width — the shell most Vidya apps put everything inside. | |
| 844 | + Tag::Page => { | |
| 845 | + let max_width = props.num("max-width", 0.0) as f32; | |
| 846 | + let pad = theme.spacing.page; | |
| 847 | + egui::ScrollArea::vertical() | |
| 848 | + .auto_shrink([false, false]) | |
| 849 | + .show(ui, |ui| { | |
| 850 | + egui::Frame::new() | |
| 851 | + .inner_margin(Margin::same(pad.clamp(0.0, 127.0) as i8)) | |
| 852 | + .show(ui, |ui| { | |
| 853 | + let avail = ui.available_width(); | |
| 854 | + let width = if max_width > 0.0 { | |
| 855 | + max_width.min(avail) | |
| 856 | + } else { | |
| 857 | + avail | |
| 858 | + }; | |
| 859 | + let indent = ((avail - width) * 0.5).max(0.0); | |
| 860 | + ui.horizontal(|ui| { | |
| 861 | + ui.add_space(indent); | |
| 862 | + ui.allocate_ui_with_layout( | |
| 863 | + Vec2::new(width, 0.0), | |
| 864 | + Layout::top_down(Align::Min), | |
| 865 | + |ui| { | |
| 866 | + ui.set_min_width(width); | |
| 867 | + ui.set_max_width(width); | |
| 868 | + vidya_core::vstack(ui, theme, |ui| { | |
| 869 | + self.paint_children(id, ui, theme); | |
| 870 | + }); | |
| 871 | + }, | |
| 872 | + ); | |
| 873 | + }); | |
| 874 | + }); | |
| 875 | + }); | |
| 876 | + } | |
| 877 | + | |
| 878 | + Tag::Scroll => { | |
| 879 | + let area = match props.str("orientation") { | |
| 880 | + "horizontal" => egui::ScrollArea::horizontal(), | |
| 881 | + "both" => egui::ScrollArea::both(), | |
| 882 | + _ => egui::ScrollArea::vertical(), | |
| 883 | + }; | |
| 884 | + // Without a bound a scroll area takes every point left in its | |
| 885 | + // parent, so anything after it — a compose bar under a message | |
| 886 | + // list — is pushed off the bottom. `:max-height` bounds it | |
| 887 | + // outright; `:reserve` bounds it by what it must leave behind, | |
| 888 | + // which is what a caller actually knows: the compose bar's | |
| 889 | + // height, not the window's. | |
| 890 | + let area = { | |
| 891 | + let reserve = props.num("reserve", 0.0) as f32; | |
| 892 | + let max_height = if reserve > 0.0 { | |
| 893 | + // Clamped against the clip rect as well as the layout's | |
| 894 | + // own idea of what is left: on Android the two differ | |
| 895 | + // once the soft keyboard takes the bottom of the | |
| 896 | + // screen, and it is the visible one that has to win or | |
| 897 | + // the row below the list is pushed off under the | |
| 898 | + // keyboard. | |
| 899 | + let visible = (ui.clip_rect().bottom() - ui.cursor().top()).max(0.0); | |
| 900 | + (ui.available_height().min(visible) - reserve).max(0.0) | |
| 901 | + } else { | |
| 902 | + props.num("max-height", 0.0) as f32 | |
| 903 | + }; | |
| 904 | + if max_height > 0.0 { | |
| 905 | + area.max_height(max_height) | |
| 906 | + } else { | |
| 907 | + area | |
| 908 | + } | |
| 909 | + }; | |
| 910 | + // Keyed by the node rather than by where it sits: egui derives | |
| 911 | + // a scroll area's id from its parent ui, so two areas that | |
| 912 | + // occupy the same place in the tree at different times — the | |
| 913 | + // message list and the picture that replaces the screen it is | |
| 914 | + // on — would otherwise share one offset, and the list would | |
| 915 | + // come back showing whatever the picture left behind. | |
| 916 | + // | |
| 917 | + // `:scroll-key` names an area that outlives its node instead. | |
| 918 | + // A node id is only as durable as the node: a list unmounted | |
| 919 | + // while another screen is up comes back as a new node, and a | |
| 920 | + // position keyed by that is a position thrown away. A caller | |
| 921 | + // that means "this same list again" says so with a name, and | |
| 922 | + // the reader returns to the line they left. | |
| 923 | + let key = { | |
| 924 | + let name = props.str("scroll-key"); | |
| 925 | + if name.is_empty() { | |
| 926 | + Id::new(("vidya_scroll", id)) | |
| 927 | + } else { | |
| 928 | + Id::new(("vidya_scroll_key", name)) | |
| 929 | + } | |
| 930 | + }; | |
| 931 | + let area = area.id_salt(key); | |
| 932 | + // A chat wants the newest line, not the oldest — except on a | |
| 933 | + // frame where something inside asked to be scrolled to. The | |
| 934 | + // two are the same control pulling opposite ways, and sticking | |
| 935 | + // wins every time it is asked, so a jump to an old message | |
| 936 | + // would land nowhere. | |
| 937 | + let sticks = props.bool("stick-to-bottom", false) && !self.wants_scroll_to(id); | |
| 938 | + let area = area.stick_to_bottom(sticks); | |
| 939 | + // `:scroll-to-bottom` is a number the caller bumps rather than | |
| 940 | + // a flag it sets: a flag would have to be cleared afterwards, | |
| 941 | + // and there is no frame in which the caller could do it. A | |
| 942 | + // value it has not seen before means "now". | |
| 943 | + let jump_key = key.with("jump"); | |
| 944 | + let jump = props.num("scroll-to-bottom", 0.0); | |
| 945 | + let jumped = ui.ctx().data(|d| d.get_temp::<f64>(jump_key)); | |
| 946 | + let jump_now = jump > 0.0 && jumped != Some(jump); | |
| 947 | + // The end is last frame's own maximum offset, kept for exactly | |
| 948 | + // this. Not f32::MAX — egui subtracts the viewport from what it | |
| 949 | + // is given, and MAX minus anything is still MAX, an offset the | |
| 950 | + // content can never reach: the area painted nothing and stayed | |
| 951 | + // that way. Not `scroll_to_rect` either, which a scroll area | |
| 952 | + // that has been scrolled away from ignores here. | |
| 953 | + let end_offset_key = key.with("end_offset"); | |
| 954 | + let area = if jump_now { | |
| 955 | + ui.ctx().data_mut(|d| d.insert_temp(jump_key, jump)); | |
| 956 | + let end = ui | |
| 957 | + .ctx() | |
| 958 | + .data(|d| d.get_temp::<f32>(end_offset_key)) | |
| 959 | + .unwrap_or(0.0); | |
| 960 | + area.vertical_scroll_offset(end) | |
| 961 | + } else { | |
| 962 | + area | |
| 963 | + }; | |
| 964 | + // Hold the content to the viewport's width, as `:page` does, | |
| 965 | + // so a wrapping child wraps at the visible edge. | |
| 966 | + let viewport_width = ui.available_width(); | |
| 967 | + let output = area.auto_shrink([false, false]).show(ui, |ui| { | |
| 968 | + ui.set_max_width(viewport_width); | |
| 969 | + self.paint_children(id, ui, theme); | |
| 970 | + // The end asked for by scrolling to it, not by setting an | |
| 971 | + // offset of f32::MAX: egui subtracts the viewport from | |
| 972 | + // whatever it is given, and MAX minus anything is still | |
| 973 | + // MAX — an offset the content can never reach, which left | |
| 974 | + // the area painting nothing at all. | |
| 975 | + | |
| 976 | + }); | |
| 977 | + | |
| 978 | + // Say when the view leaves the end and when it comes back, so | |
| 979 | + // a caller can offer the way back. Reported on change only: the | |
| 980 | + // position itself changes every frame of a scroll, and an event | |
| 981 | + // a frame is not news. | |
| 982 | + // Within a line of the end counts as the end, and content | |
| 983 | + // shorter than the viewport is always at it. | |
| 984 | + let max_offset = (output.content_size.y - output.inner_rect.height()).max(0.0); | |
| 985 | + // What `:scroll-to-bottom` will aim at next time it is asked. | |
| 986 | + ui.ctx() | |
| 987 | + .data_mut(|d| d.insert_temp(end_offset_key, max_offset)); | |
| 988 | + let at_end = output.state.offset.y >= max_offset - 24.0; | |
| 989 | + // Reaching the end is reported at once; leaving it has to hold | |
| 990 | + // for a few frames first. A burst of arriving messages grows | |
| 991 | + // the content faster than the offset follows it, and reporting | |
| 992 | + // that honestly would blink "scrolled away" whenever a channel | |
| 993 | + // is busy. | |
| 994 | + let end_key = key.with("at_end"); | |
| 995 | + let away_key = key.with("away_frames"); | |
| 996 | + let away_frames = ui.ctx().data(|d| d.get_temp::<u32>(away_key)).unwrap_or(0); | |
| 997 | + let away_frames = if at_end { 0 } else { away_frames.saturating_add(1) }; | |
| 998 | + ui.ctx().data_mut(|d| d.insert_temp(away_key, away_frames)); | |
| 999 | + | |
| 1000 | + let settled = if at_end { | |
| 1001 | + Some(true) | |
| 1002 | + } else if away_frames >= 3 { | |
| 1003 | + Some(false) | |
| 1004 | + } else { | |
| 1005 | + None | |
| 1006 | + }; | |
| 1007 | + if let Some(at_end) = settled { | |
| 1008 | + let was_at_end = ui.ctx().data(|d| d.get_temp::<bool>(end_key)); | |
| 1009 | + if was_at_end != Some(at_end) { | |
| 1010 | + ui.ctx().data_mut(|d| d.insert_temp(end_key, at_end)); | |
| 1011 | + // Only after the first report: the opening one would | |
| 1012 | + // arrive before the content has a height. | |
| 1013 | + if was_at_end.is_some() { | |
| 1014 | + self.emit( | |
| 1015 | + id, | |
| 1016 | + "change", | |
| 1017 | + if at_end { "end" } else { "away" }.to_owned(), | |
| 1018 | + if at_end { 1.0 } else { 0.0 }, | |
| 1019 | + ); | |
| 1020 | + } | |
| 1021 | + } | |
| 1022 | + } | |
| 1023 | + } | |
| 1024 | + | |
| 1025 | + Tag::Card => { | |
| 1026 | + vidya_core::card(ui, theme, |ui| self.paint_children(id, ui, theme)); | |
| 1027 | + } | |
| 1028 | + | |
| 1029 | + // A card with a heading — glimmer-tui's `:frame` label, in the | |
| 1030 | + // idiom this theme actually has for one. | |
| 1031 | + Tag::Frame => { | |
| 1032 | + let label = props.label(); | |
| 1033 | + vidya_core::card(ui, theme, |ui| { | |
| 1034 | + if !label.is_empty() { | |
| 1035 | + vidya_core::title_2(ui, theme, label); | |
| 1036 | + } | |
| 1037 | + self.paint_children(id, ui, theme); | |
| 1038 | + }); | |
| 1039 | + } | |
| 1040 | + | |
| 1041 | + Tag::Label => vidya_core::body(ui, theme, props.label()), | |
| 1042 | + | |
| 1043 | + // Body text that answers the pointer: the accent colour and the | |
| 1044 | + // hand cursor are the whole affordance, and the click is reported | |
| 1045 | + // like a button's so the caller decides what opening it means. | |
| 1046 | + Tag::Link => { | |
| 1047 | + let response = ui | |
| 1048 | + .add( | |
| 1049 | + egui::Label::new( | |
| 1050 | + egui::RichText::new(props.label()) | |
| 1051 | + .size(theme.type_scale.body) | |
| 1052 | + .color(theme.palette.accent), | |
| 1053 | + ) | |
| 1054 | + .wrap() | |
| 1055 | + .sense(egui::Sense::click()), | |
| 1056 | + ) | |
| 1057 | + .on_hover_cursor(egui::CursorIcon::PointingHand); | |
| 1058 | + if response.clicked() { | |
| 1059 | + self.emit(id, "click", props.label().to_owned(), 0.0); | |
| 1060 | + } | |
| 1061 | + } | |
| 1062 | + Tag::Title => vidya_core::title(ui, theme, props.label()), | |
| 1063 | + Tag::Title2 => vidya_core::title_2(ui, theme, props.label()), | |
| 1064 | + Tag::DimLabel => vidya_core::dim_label(ui, theme, props.label()), | |
| 1065 | + | |
| 1066 | + Tag::Button => { | |
| 1067 | + let kind = match props.str("kind") { | |
| 1068 | + "primary" => 1, | |
| 1069 | + "destructive" => 2, | |
| 1070 | + _ => 0, | |
| 1071 | + }; | |
| 1072 | + if crate::ui::button(ui, theme, props.label(), kind) { | |
| 1073 | + self.emit(id, "click", String::new(), 0.0); | |
| 1074 | + } | |
| 1075 | + } | |
| 1076 | + | |
| 1077 | + Tag::CheckButton => { | |
| 1078 | + let was = props.bool("active", false); | |
| 1079 | + let (now, changed) = crate::ui::checkbox(ui, theme, was, props.label()); | |
| 1080 | + if changed { | |
| 1081 | + // The widget does not own the value: the new state is | |
| 1082 | + // written back so a component that ignores `:on-toggled` | |
| 1083 | + // still tracks the click, and the handler decides whether | |
| 1084 | + // it survives the next render of `:active`. | |
| 1085 | + self.set(id, "active", Value::Bool(now)); | |
| 1086 | + self.emit(id, "toggled", String::new(), if now { 1.0 } else { 0.0 }); | |
| 1087 | + } | |
| 1088 | + } | |
| 1089 | + | |
| 1090 | + Tag::Entry => { | |
| 1091 | + let mut text = props.str("text").to_owned(); | |
| 1092 | + let placeholder = props.str("placeholder").to_owned(); | |
| 1093 | + let rows = props.num("rows", 4.0) as usize; | |
| 1094 | + let response = if props.bool("multiline", false) { | |
| 1095 | + vidya_core::text_field_multiline(ui, theme, &mut text, rows.max(1)) | |
| 1096 | + } else { | |
| 1097 | + crate::ui::text_field(ui, theme, &mut text, &placeholder) | |
| 1098 | + }; | |
| 1099 | + if text != props.str("text") { | |
| 1100 | + self.set(id, "text", Value::Str(text.clone())); | |
| 1101 | + self.emit(id, "change", text, 0.0); | |
| 1102 | + } | |
| 1103 | + if response.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) { | |
| 1104 | + self.emit(id, "activate", String::new(), 0.0); | |
| 1105 | + } | |
| 1106 | + // A paste of something that is not text. egui turns Ctrl+V | |
| 1107 | + // into a `Paste` event carrying the clipboard's text, and a | |
| 1108 | + // clipboard holding a picture has none — so the keystroke | |
| 1109 | + // arrives as a key press with no paste behind it, and the | |
| 1110 | + // field would otherwise swallow it. Reported instead, for a | |
| 1111 | + // caller that has somewhere to put a picture; one that has not | |
| 1112 | + // ignores it and the keystroke stays as inert as it was. | |
| 1113 | + // | |
| 1114 | + // The clipboard is not read here: whether there is a picture | |
| 1115 | + // on it is answered by `vidya_clipboard_image_png`, and asking | |
| 1116 | + // twice would copy every pasted image for nothing. | |
| 1117 | + if response.has_focus() { | |
| 1118 | + let paste_without_text = ui.input(|i| { | |
| 1119 | + i.events.iter().any(|e| { | |
| 1120 | + matches!( | |
| 1121 | + e, | |
| 1122 | + egui::Event::Key { | |
| 1123 | + key: egui::Key::V, | |
| 1124 | + pressed: true, | |
| 1125 | + modifiers, | |
| 1126 | + .. | |
| 1127 | + } if modifiers.command | |
| 1128 | + ) | |
| 1129 | + }) && !i | |
| 1130 | + .events | |
| 1131 | + .iter() | |
| 1132 | + .any(|e| matches!(e, egui::Event::Paste(_))) | |
| 1133 | + }); | |
| 1134 | + if paste_without_text { | |
| 1135 | + self.emit(id, "paste-empty", String::new(), 0.0); | |
| 1136 | + } | |
| 1137 | + } | |
| 1138 | + } | |
| 1139 | + | |
| 1140 | + Tag::Separator => crate::ui::separator(ui), | |
| 1141 | + Tag::Spacer => crate::ui::gap(ui, props.num("size", theme.spacing.md as f64) as f32), | |
| 1142 | + Tag::Status => crate::ui::status(ui, theme, props.label(), props.bool("live", false)), | |
| 1143 | + | |
| 1144 | + Tag::Progress => { | |
| 1145 | + let value = props.num("value", 0.0) as f32; | |
| 1146 | + let mut bar = egui::ProgressBar::new(value.clamp(0.0, 1.0)); | |
| 1147 | + if !props.label().is_empty() { | |
| 1148 | + bar = bar.text(props.label()); | |
| 1149 | + } | |
| 1150 | + ui.add(bar); | |
| 1151 | + } | |
| 1152 | + | |
| 1153 | + // A picture from a file the caller has already fetched. Decoded | |
| 1154 | + // once and kept as a texture: the tree is walked every frame, and | |
| 1155 | + // decoding a PNG sixty times a second is not a thing to do. | |
| 1156 | + // Someone's face, or the next best thing. A chat wants one column | |
| 1157 | + // of them down the left, so this is a fixed square whatever the | |
| 1158 | + // picture's own proportions are, and there is always something to | |
| 1159 | + // draw: a name with no picture behind it becomes its initial on a | |
| 1160 | + // colour of its own, which keeps the column straight and still | |
| 1161 | + // tells one person from another at a glance. | |
| 1162 | + Tag::Avatar => { | |
| 1163 | + let size = props.num("size", 24.0) as f32; | |
| 1164 | + let label = props.label().to_owned(); | |
| 1165 | + let path = props.str("src").to_owned(); | |
| 1166 | + let (rect, response) = | |
| 1167 | + ui.allocate_exact_size(Vec2::splat(size), egui::Sense::click()); | |
| 1168 | + | |
| 1169 | + let texture = if path.is_empty() { | |
| 1170 | + None | |
| 1171 | + } else { | |
| 1172 | + self.texture(ui, &path) | |
| 1173 | + }; | |
| 1174 | + match texture { | |
| 1175 | + // A corner radius of half the side is a circle. | |
| 1176 | + Some(texture) => egui::Image::new(egui::load::SizedTexture::new( | |
| 1177 | + texture.id(), | |
| 1178 | + Vec2::splat(size), | |
| 1179 | + )) | |
| 1180 | + .corner_radius(size * 0.5) | |
| 1181 | + .paint_at(ui, rect), | |
| 1182 | + None => { | |
| 1183 | + let initial = label | |
| 1184 | + .trim_start_matches(['#', '&', '@', '+', '%', '~']) | |
| 1185 | + .chars() | |
| 1186 | + .next() | |
| 1187 | + .map(|c| c.to_uppercase().to_string()) | |
| 1188 | + .unwrap_or_else(|| "?".to_owned()); | |
| 1189 | + ui.painter() | |
| 1190 | + .circle_filled(rect.center(), size * 0.5, name_colour(&label, theme)); | |
| 1191 | + ui.painter().text( | |
| 1192 | + rect.center(), | |
| 1193 | + Align2::CENTER_CENTER, | |
| 1194 | + initial, | |
| 1195 | + FontId::proportional((size * 0.45).max(9.0)), | |
| 1196 | + theme.palette.accent_fg, | |
| 1197 | + ); | |
| 1198 | + } | |
| 1199 | + } | |
| 1200 | + if response.clicked() { | |
| 1201 | + self.emit(id, "click", label, 0.0); | |
| 1202 | + } | |
| 1203 | + } | |
| 1204 | + | |
| 1205 | + // A reaction chip: the emoji drawn from the Twemoji pack rather | |
| 1206 | + // than set as text, so it is the colour picture people expect and | |
| 1207 | + // not a monochrome glyph — or, where the font has no glyph at all, | |
| 1208 | + // tofu. `:count` rides beside it once more than one person is on | |
| 1209 | + // it, and `:mine` is what marks the ones you put there yourself. | |
| 1210 | + Tag::Reaction => { | |
| 1211 | + let emoji = props.str("emoji").to_owned(); | |
| 1212 | + let emoji = if emoji.is_empty() { | |
| 1213 | + props.label().to_owned() | |
| 1214 | + } else { | |
| 1215 | + emoji | |
| 1216 | + }; | |
| 1217 | + let count = props.num("count", 0.0).max(0.0) as usize; | |
| 1218 | + let mine = props.bool("mine", false); | |
| 1219 | + // `:size` is the glyph's, and the pill is sized from it. | |
| 1220 | + let size = props.num("size", 0.0) as f32; | |
| 1221 | + let response = if size > 0.0 { | |
| 1222 | + vidya_core::reaction_chip_sized(ui, theme, &emoji, count, mine, size) | |
| 1223 | + } else { | |
| 1224 | + vidya_core::reaction_chip(ui, theme, &emoji, count, mine) | |
| 1225 | + }; | |
| 1226 | + if response.clicked() { | |
| 1227 | + self.emit(id, "click", emoji, count as f64); | |
| 1228 | + } | |
| 1229 | + } | |
| 1230 | + | |
| 1231 | + Tag::Image => { | |
| 1232 | + // Two sources, one tag: a `src` is a file decoded once and | |
| 1233 | + // cached by its path, a `feed` is live pixels pushed in under | |
| 1234 | + // a name (`vidya_frame_rgba`) and re-uploaded as they arrive. | |
| 1235 | + // Everything downstream — fit, bounds, the click — is the same | |
| 1236 | + // for both, which is why this is a prop and not a second tag. | |
| 1237 | + let feed = props.str("feed").to_owned(); | |
| 1238 | + let path = props.str("src").to_owned(); | |
| 1239 | + let max_width = props.num("max-width", 0.0) as f32; | |
| 1240 | + let texture = if !feed.is_empty() { | |
| 1241 | + self.feed_texture(ui, &feed) | |
| 1242 | + } else if !path.is_empty() { | |
| 1243 | + self.texture(ui, &path) | |
| 1244 | + } else { | |
| 1245 | + return; | |
| 1246 | + }; | |
| 1247 | + let Some(texture) = texture else { | |
| 1248 | + // A file that will not decode is not worth a broken-image | |
| 1249 | + // glyph; the message text beside it already says what it | |
| 1250 | + // was meant to be. A feed that has had no frame yet is the | |
| 1251 | + // same: the tile appears when the first one lands. | |
| 1252 | + return; | |
| 1253 | + }; | |
| 1254 | + let size = texture.size_vec2(); | |
| 1255 | + | |
| 1256 | + // `:fit` gives the picture every point of the space it has | |
| 1257 | + // been handed and centres it in it — a picture on a screen of | |
| 1258 | + // its own, rather than one in a line of chat. It is the one | |
| 1259 | + // case that scales *up*: a picture opened to be looked at is | |
| 1260 | + // meant to fill the window, and how big the window is this | |
| 1261 | + // frame is something only this side knows. Everywhere else the | |
| 1262 | + // caller's `:max-height` bounds it and nothing is enlarged | |
| 1263 | + // past its own pixels. | |
| 1264 | + if props.bool("fit", false) { | |
| 1265 | + let space = ui.available_size(); | |
| 1266 | + if space.x <= 0.0 || space.y <= 0.0 || size.x <= 0.0 || size.y <= 0.0 { | |
| 1267 | + return; | |
| 1268 | + } | |
| 1269 | + let scale = (space.x / size.x).min(space.y / size.y); | |
| 1270 | + let (rect, response) = | |
| 1271 | + ui.allocate_exact_size(space, egui::Sense::click()); | |
| 1272 | + let painted = | |
| 1273 | + egui::Rect::from_center_size(rect.center(), size * scale); | |
| 1274 | + egui::Image::new(egui::load::SizedTexture::new(texture.id(), size * scale)) | |
| 1275 | + .paint_at(ui, painted); | |
| 1276 | + if response.clicked() { | |
| 1277 | + self.emit(id, "click", String::new(), 0.0); | |
| 1278 | + } | |
| 1279 | + return; | |
| 1280 | + } | |
| 1281 | + | |
| 1282 | + let max_height = props.num("max-height", 240.0) as f32; | |
| 1283 | + let avail = if max_width > 0.0 { | |
| 1284 | + max_width.min(ui.available_width()) | |
| 1285 | + } else { | |
| 1286 | + ui.available_width() | |
| 1287 | + }; | |
| 1288 | + let scale = (avail / size.x).min(max_height / size.y).min(1.0); | |
| 1289 | + // Clickable whether or not the caller listens: the tree does | |
| 1290 | + // not know which nodes have handlers, and an unheard event | |
| 1291 | + // costs a queue slot. | |
| 1292 | + let response = ui | |
| 1293 | + .add( | |
| 1294 | + egui::Image::new(egui::load::SizedTexture::new(texture.id(), size * scale)) | |
| 1295 | + .corner_radius(theme.spacing.radius_sm) | |
| 1296 | + .sense(egui::Sense::click()), | |
| 1297 | + ) | |
| 1298 | + .on_hover_cursor(egui::CursorIcon::PointingHand); | |
| 1299 | + if response.clicked() { | |
| 1300 | + self.emit(id, "click", String::new(), 0.0); | |
| 1301 | + } | |
| 1302 | + } | |
| 1303 | + | |
| 1304 | + Tag::Spinner => { | |
| 1305 | + ui.horizontal(|ui| { | |
| 1306 | + ui.add(egui::Spinner::new()); | |
| 1307 | + if !props.label().is_empty() { | |
| 1308 | + vidya_core::body(ui, theme, props.label()); | |
| 1309 | + } | |
| 1310 | + }); | |
| 1311 | + } | |
| 1312 | + } | |
| 1313 | + } | |
| 1314 | + | |
| 1315 | + /// Wrap `add` in the node's `:margin`, when it has one. | |
| 1316 | + fn with_margin(&mut self, props: &Props, ui: &mut Ui, add: impl FnOnce(&mut Self, &mut Ui)) { | |
| 1317 | + // `:margin` sets all four sides; `:margin-top` and its siblings say | |
| 1318 | + // otherwise for one of them. A row that sits at the bottom of a screen | |
| 1319 | + // wants its space above it, not under it, and that is not a thing a | |
| 1320 | + // single number can express. | |
| 1321 | + let side = |key: &str| { | |
| 1322 | + props.num(key, props.num("margin", 0.0)).clamp(0.0, 127.0) as i8 | |
| 1323 | + }; | |
| 1324 | + let margin = Margin { | |
| 1325 | + left: side("margin-left"), | |
| 1326 | + right: side("margin-right"), | |
| 1327 | + top: side("margin-top"), | |
| 1328 | + bottom: side("margin-bottom"), | |
| 1329 | + }; | |
| 1330 | + if margin == Margin::ZERO { | |
| 1331 | + add(self, ui); | |
| 1332 | + return; | |
| 1333 | + } | |
| 1334 | + egui::Frame::new() | |
| 1335 | + .inner_margin(margin) | |
| 1336 | + .show(ui, |ui| add(self, ui)); | |
| 1337 | + } | |
| 1338 | +} | |
| 1339 | + | |
| 1340 | +/// Typed reads over a node's prop map, with the defaults each widget wants. | |
| 1341 | +struct Props(HashMap<String, Value>); | |
| 1342 | + | |
| 1343 | +impl Props { | |
| 1344 | + fn str(&self, key: &str) -> &str { | |
| 1345 | + match self.0.get(key) { | |
| 1346 | + Some(Value::Str(s)) => s, | |
| 1347 | + _ => "", | |
| 1348 | + } | |
| 1349 | + } | |
| 1350 | + | |
| 1351 | + fn num(&self, key: &str, default: f64) -> f64 { | |
| 1352 | + match self.0.get(key) { | |
| 1353 | + Some(Value::Num(n)) => *n, | |
| 1354 | + Some(Value::Bool(b)) => { | |
| 1355 | + if *b { | |
| 1356 | + 1.0 | |
| 1357 | + } else { | |
| 1358 | + 0.0 | |
| 1359 | + } | |
| 1360 | + } | |
| 1361 | + _ => default, | |
| 1362 | + } | |
| 1363 | + } | |
| 1364 | + | |
| 1365 | + fn bool(&self, key: &str, default: bool) -> bool { | |
| 1366 | + match self.0.get(key) { | |
| 1367 | + Some(Value::Bool(b)) => *b, | |
| 1368 | + Some(Value::Num(n)) => *n != 0.0, | |
| 1369 | + _ => default, | |
| 1370 | + } | |
| 1371 | + } | |
| 1372 | + | |
| 1373 | + /// `:label` is the family's name for a widget's text; `:text` is what a | |
| 1374 | + /// label is also allowed to use (and what an entry always uses). | |
| 1375 | + fn label(&self) -> &str { | |
| 1376 | + let label = self.str("label"); | |
| 1377 | + if label.is_empty() { | |
| 1378 | + self.str("text") | |
| 1379 | + } else { | |
| 1380 | + label | |
| 1381 | + } | |
| 1382 | + } | |
| 1383 | +} | |
| 1384 | + | |
| 1385 | +#[cfg(test)] | |
| 1386 | +mod tests { | |
| 1387 | + use super::*; | |
| 1388 | + | |
| 1389 | + fn kids(tree: &Tree, id: u32) -> Vec<u32> { | |
| 1390 | + tree.slot(id) | |
| 1391 | + .map(|n| n.children.clone()) | |
| 1392 | + .unwrap_or_default() | |
| 1393 | + } | |
| 1394 | + | |
| 1395 | + #[test] | |
| 1396 | + fn root_exists_and_is_a_window() { | |
| 1397 | + let tree = Tree::default(); | |
| 1398 | + assert!(tree.exists(tree.root())); | |
| 1399 | + assert_eq!(tree.slot(tree.root()).unwrap().tag, Tag::Window); | |
| 1400 | + } | |
| 1401 | + | |
| 1402 | + #[test] | |
| 1403 | + fn append_parents_once_even_when_reparenting() { | |
| 1404 | + let mut tree = Tree::default(); | |
| 1405 | + let a = tree.new_node("vbox"); | |
| 1406 | + let b = tree.new_node("hbox"); | |
| 1407 | + let leaf = tree.new_node("label"); | |
| 1408 | + tree.append(tree.root(), a); | |
| 1409 | + tree.append(tree.root(), b); | |
| 1410 | + | |
| 1411 | + tree.append(a, leaf); | |
| 1412 | + tree.append(b, leaf); | |
| 1413 | + assert_eq!(kids(&tree, a), vec![]); | |
| 1414 | + assert_eq!(kids(&tree, b), vec![leaf]); | |
| 1415 | + } | |
| 1416 | + | |
| 1417 | + #[test] | |
| 1418 | + fn a_cycle_is_refused() { | |
| 1419 | + let mut tree = Tree::default(); | |
| 1420 | + let outer = tree.new_node("vbox"); | |
| 1421 | + let inner = tree.new_node("vbox"); | |
| 1422 | + tree.append(tree.root(), outer); | |
| 1423 | + tree.append(outer, inner); | |
| 1424 | + assert!(!tree.append(inner, outer)); | |
| 1425 | + assert_eq!(kids(&tree, inner), vec![]); | |
| 1426 | + } | |
| 1427 | + | |
| 1428 | + #[test] | |
| 1429 | + fn remove_frees_the_whole_subtree_and_reuses_slots() { | |
| 1430 | + let mut tree = Tree::default(); | |
| 1431 | + let parent = tree.new_node("vbox"); | |
| 1432 | + let child = tree.new_node("label"); | |
| 1433 | + tree.append(tree.root(), parent); | |
| 1434 | + tree.append(parent, child); | |
| 1435 | + | |
| 1436 | + tree.remove(tree.root(), parent); | |
| 1437 | + assert!(!tree.exists(parent)); | |
| 1438 | + assert!(!tree.exists(child)); | |
| 1439 | + assert_eq!(tree.new_node("label"), child); | |
| 1440 | + } | |
| 1441 | + | |
| 1442 | + #[test] | |
| 1443 | + fn remove_ignores_a_child_of_someone_else() { | |
| 1444 | + let mut tree = Tree::default(); | |
| 1445 | + let a = tree.new_node("vbox"); | |
| 1446 | + let b = tree.new_node("vbox"); | |
| 1447 | + let leaf = tree.new_node("label"); | |
| 1448 | + tree.append(tree.root(), a); | |
| 1449 | + tree.append(tree.root(), b); | |
| 1450 | + tree.append(a, leaf); | |
| 1451 | + | |
| 1452 | + tree.remove(b, leaf); | |
| 1453 | + assert!(tree.exists(leaf)); | |
| 1454 | + assert_eq!(kids(&tree, a), vec![leaf]); | |
| 1455 | + } | |
| 1456 | + | |
| 1457 | + #[test] | |
| 1458 | + fn insert_after_reorders_in_both_directions() { | |
| 1459 | + let mut tree = Tree::default(); | |
| 1460 | + let parent = tree.new_node("vbox"); | |
| 1461 | + tree.append(tree.root(), parent); | |
| 1462 | + let a = tree.new_node("label"); | |
| 1463 | + let b = tree.new_node("label"); | |
| 1464 | + let c = tree.new_node("label"); | |
| 1465 | + for id in [a, b, c] { | |
| 1466 | + tree.append(parent, id); | |
| 1467 | + } | |
| 1468 | + | |
| 1469 | + // Move a forward, past two siblings. | |
| 1470 | + assert!(tree.insert_after(parent, a, c)); | |
| 1471 | + assert_eq!(kids(&tree, parent), vec![b, c, a]); | |
| 1472 | + // And back to the front. | |
| 1473 | + assert!(tree.insert_after(parent, a, 0)); | |
| 1474 | + assert_eq!(kids(&tree, parent), vec![a, b, c]); | |
| 1475 | + // A no-op move keeps the order it already had. | |
| 1476 | + assert!(tree.insert_after(parent, b, a)); | |
| 1477 | + assert_eq!(kids(&tree, parent), vec![a, b, c]); | |
| 1478 | + } | |
| 1479 | + | |
| 1480 | + #[test] | |
| 1481 | + fn replace_swaps_in_place_and_drops_the_old_node() { | |
| 1482 | + let mut tree = Tree::default(); | |
| 1483 | + let parent = tree.new_node("vbox"); | |
| 1484 | + tree.append(tree.root(), parent); | |
| 1485 | + let a = tree.new_node("label"); | |
| 1486 | + let b = tree.new_node("label"); | |
| 1487 | + let c = tree.new_node("button"); | |
| 1488 | + tree.append(parent, a); | |
| 1489 | + tree.append(parent, b); | |
| 1490 | + | |
| 1491 | + assert!(tree.replace(parent, a, c)); | |
| 1492 | + assert_eq!(kids(&tree, parent), vec![c, b]); | |
| 1493 | + assert!(!tree.exists(a)); | |
| 1494 | + } | |
| 1495 | + | |
| 1496 | + #[test] | |
| 1497 | + fn props_round_trip_and_clear() { | |
| 1498 | + let mut tree = Tree::default(); | |
| 1499 | + let id = tree.new_node("button"); | |
| 1500 | + tree.set(id, "label", Value::Str("Save".into())); | |
| 1501 | + tree.set(id, "value", Value::Num(0.5)); | |
| 1502 | + tree.set(id, "active", Value::Bool(true)); | |
| 1503 | + assert_eq!(tree.get(id, "label"), Some(&Value::Str("Save".into()))); | |
| 1504 | + assert_eq!(tree.get(id, "value"), Some(&Value::Num(0.5))); | |
| 1505 | + assert_eq!(tree.get(id, "active"), Some(&Value::Bool(true))); | |
| 1506 | + | |
| 1507 | + tree.clear_props(id); | |
| 1508 | + assert_eq!(tree.get(id, "label"), None); | |
| 1509 | + } | |
| 1510 | + | |
| 1511 | + #[test] | |
| 1512 | + fn events_drain_in_order_and_skip_removed_nodes() { | |
| 1513 | + let mut tree = Tree::default(); | |
| 1514 | + let a = tree.new_node("button"); | |
| 1515 | + let b = tree.new_node("button"); | |
| 1516 | + tree.append(tree.root(), a); | |
| 1517 | + tree.append(tree.root(), b); | |
| 1518 | + tree.emit(a, "click", String::new(), 0.0); | |
| 1519 | + tree.emit(b, "click", String::new(), 0.0); | |
| 1520 | + | |
| 1521 | + // Dropping `a` must drop the event still queued against it, or it would | |
| 1522 | + // be routed to a handler the caller has already forgotten. | |
| 1523 | + tree.remove(tree.root(), a); | |
| 1524 | + assert!(tree.poll()); | |
| 1525 | + assert_eq!(tree.current().unwrap().node, b); | |
| 1526 | + assert!(!tree.poll()); | |
| 1527 | + assert!(tree.current().is_none()); | |
| 1528 | + } | |
| 1529 | + | |
| 1530 | + #[test] | |
| 1531 | + fn unknown_tags_are_kept_as_boxes() { | |
| 1532 | + let mut tree = Tree::default(); | |
| 1533 | + let id = tree.new_node("carousel"); | |
| 1534 | + assert!(tree.exists(id)); | |
| 1535 | + assert_eq!(tree.slot(id).unwrap().tag, Tag::Unknown("carousel".to_owned())); | |
| 1536 | + } | |
| 1537 | + #[test] | |
| 1538 | + fn dump_is_hiccup_of_what_the_tree_holds() { | |
| 1539 | + let mut tree = Tree::default(); | |
| 1540 | + let root = tree.new_node("vbox"); | |
| 1541 | + tree.set(root, "spacing", Value::Num(8.0)); | |
| 1542 | + tree.set(root, "orientation", Value::Str("vertical".to_owned())); | |
| 1543 | + let button = tree.new_node("button"); | |
| 1544 | + tree.set(button, "label", Value::Str("go".to_owned())); | |
| 1545 | + tree.set(button, "sensitive", Value::Bool(false)); | |
| 1546 | + tree.append(root, button); | |
| 1547 | + | |
| 1548 | + assert_eq!( | |
| 1549 | + tree.dump(root), | |
| 1550 | + "[:box {:orientation \"vertical\" :spacing 8}\n \ | |
| 1551 | + [:button {:label \"go\" :sensitive false}]]" | |
| 1552 | + ); | |
| 1553 | + } | |
| 1554 | + | |
| 1555 | + #[test] | |
| 1556 | + fn dump_keeps_an_unknown_tag_and_escapes_a_string() { | |
| 1557 | + let mut tree = Tree::default(); | |
| 1558 | + let id = tree.new_node("carousel"); | |
| 1559 | + tree.set(id, "label", Value::Str("a \"quote\"\nand a line".to_owned())); | |
| 1560 | + assert_eq!( | |
| 1561 | + tree.dump(id), | |
| 1562 | + "[:carousel {:label \"a \\\"quote\\\"\\nand a line\"}]" | |
| 1563 | + ); | |
| 1564 | + assert_eq!(tree.dump(9999), "nil"); | |
| 1565 | + } | |
| 1566 | + | |
| 1567 | + #[test] | |
| 1568 | + fn a_frame_is_kept_for_the_paint_that_will_upload_it() { | |
| 1569 | + let mut tree = Tree::default(); | |
| 1570 | + assert!(tree.set_frame("nandi", 2, 2, &[0u8; 16])); | |
| 1571 | + assert!(tree.feeds["nandi"].pending.is_some()); | |
| 1572 | + | |
| 1573 | + // The newest frame is the only one worth painting: a second one | |
| 1574 | + // arriving before the first was drawn replaces it rather than queuing. | |
| 1575 | + assert!(tree.set_frame("nandi", 2, 2, &[7u8; 16])); | |
| 1576 | + let pending = tree.feeds["nandi"].pending.as_ref().unwrap(); | |
| 1577 | + assert_eq!(pending.size, [2, 2]); | |
| 1578 | + assert_eq!(tree.feeds.len(), 1); | |
| 1579 | + } | |
| 1580 | + | |
| 1581 | + #[test] | |
| 1582 | + fn a_frame_that_does_not_match_its_dimensions_is_refused() { | |
| 1583 | + let mut tree = Tree::default(); | |
| 1584 | + // Short of 2x2x4 — a capture path that changed resolution mid-stream | |
| 1585 | + // would otherwise paint the tail of the old buffer as the new one. | |
| 1586 | + assert!(!tree.set_frame("nandi", 2, 2, &[0u8; 15])); | |
| 1587 | + assert!(!tree.set_frame("nandi", 0, 2, &[])); | |
| 1588 | + assert!(!tree.set_frame("", 2, 2, &[0u8; 16])); | |
| 1589 | + assert!(tree.feeds.is_empty()); | |
| 1590 | + } | |
| 1591 | + | |
| 1592 | + #[test] | |
| 1593 | + fn dropping_a_feed_forgets_it() { | |
| 1594 | + let mut tree = Tree::default(); | |
| 1595 | + tree.set_frame("nandi", 1, 1, &[0u8; 4]); | |
| 1596 | + assert!(tree.drop_frame("nandi")); | |
| 1597 | + assert!(!tree.drop_frame("nandi")); | |
| 1598 | + assert!(tree.feeds.is_empty()); | |
| 1599 | + } | |
| 1600 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,1600 @@ | |||
| 1 | +//! A retained node tree, painted immediately. | ||
| 2 | +//! | ||
| 3 | +//! The push/pop half of this ABI (`vidya_card_begin` … `vidya_card_end`) suits | ||
| 4 | +//! a caller that writes its UI out top to bottom every frame. A *reactive* | ||
| 5 | +//! caller does not: glimmer keeps a component tree, reconciles it against new | ||
| 6 | +//! hiccup, and emits create/patch/append/remove against whatever the toolkit | ||
| 7 | +//! calls a widget. GTK has widgets to hand it; egui has none. | ||
| 8 | +//! | ||
| 9 | +//! So this module is the widget layer glimmer expects, on the Rust side of the | ||
| 10 | +//! FFI. The caller gets integer node handles and mutates them — set a prop, | ||
| 11 | +//! append a child, drop a subtree. Nothing is drawn by those calls. Once a | ||
| 12 | +//! frame, [`Tree::paint`] walks the whole tree and emits the egui calls it | ||
| 13 | +//! describes, and interactions come back out as a queue of events the caller | ||
| 14 | +//! drains and routes to its own handlers. | ||
| 15 | +//! | ||
| 16 | +//! Two things fall out of that split that the push/pop ABI could not have: | ||
| 17 | +//! | ||
| 18 | +//! * **Closure-shaped egui APIs work.** `ScrollArea`, `Frame` and friends take | ||
| 19 | +//! an `FnOnce(&mut Ui)` and keep their `begin`/`end` private, which is why | ||
| 20 | +//! `vidya_page_begin` had to reimplement scrolling by hand and why the page | ||
| 21 | +//! was documented as non-scrolling. Painting from a tree we already hold | ||
| 22 | +//! means the recursion *is* the closure; nothing has to stay open across a | ||
| 23 | +//! call boundary. | ||
| 24 | +//! * **FFI traffic tracks edits, not frames.** A static UI at 60fps costs zero | ||
| 25 | +//! crossings per frame; only what the reconciler actually changed is sent. | ||
| 26 | +//! | ||
| 27 | +//! The tree deliberately knows nothing about egui until [`Tree::paint`], so the | ||
| 28 | +//! arena and its edit operations are unit-testable with no window. | ||
| 29 | + | ||
| 30 | +use std::collections::HashMap; | ||
| 31 | +use std::collections::VecDeque; | ||
| 32 | + | ||
| 33 | +use egui::{Align, Align2, Color32, FontId, Id, Layout, Margin, TextureOptions, Ui, Vec2}; | ||
| 34 | +use vidya_core::Theme; | ||
| 35 | + | ||
| 36 | +/// A prop value. The three types the ABI can carry, and all glimmer needs: | ||
| 37 | +/// keywords and colours arrive as strings, numbers as doubles, flags as ints. | ||
| 38 | +#[derive(Clone, Debug, PartialEq)] | ||
| 39 | +pub enum Value { | ||
| 40 | + Str(String), | ||
| 41 | + Num(f64), | ||
| 42 | + Bool(bool), | ||
| 43 | +} | ||
| 44 | + | ||
| 45 | +/// What a node renders as. Unknown tags are kept rather than rejected: they | ||
| 46 | +/// paint as a plain vertical box, so a caller using a tag this backend has not | ||
| 47 | +/// grown yet still sees its children instead of nothing. | ||
| 48 | +#[derive(Clone, Debug, PartialEq, Eq)] | ||
| 49 | +pub enum Tag { | ||
| 50 | + Window, | ||
| 51 | + Box, | ||
| 52 | + Page, | ||
| 53 | + Card, | ||
| 54 | + Frame, | ||
| 55 | + Scroll, | ||
| 56 | + Label, | ||
| 57 | + Link, | ||
| 58 | + Title, | ||
| 59 | + Title2, | ||
| 60 | + DimLabel, | ||
| 61 | + Button, | ||
| 62 | + CheckButton, | ||
| 63 | + Entry, | ||
| 64 | + Separator, | ||
| 65 | + Spacer, | ||
| 66 | + Progress, | ||
| 67 | + Spinner, | ||
| 68 | + Image, | ||
| 69 | + Avatar, | ||
| 70 | + Reaction, | ||
| 71 | + Status, | ||
| 72 | + /// A tag this backend has not grown yet, keeping the name it was created | ||
| 73 | + /// with so a dump answers what the caller actually asked for. | ||
| 74 | + Unknown(String), | ||
| 75 | +} | ||
| 76 | + | ||
| 77 | +impl Tag { | ||
| 78 | + /// Parse a hiccup tag name. `:hbox`/`:vbox` are the same box — the tag only | ||
| 79 | + /// implies an orientation, which the caller sets as a prop. | ||
| 80 | + fn parse(name: &str) -> Self { | ||
| 81 | + match name { | ||
| 82 | + "window" => Self::Window, | ||
| 83 | + "box" | "hbox" | "vbox" => Self::Box, | ||
| 84 | + "page" => Self::Page, | ||
| 85 | + "card" => Self::Card, | ||
| 86 | + "frame" => Self::Frame, | ||
| 87 | + "scroll" => Self::Scroll, | ||
| 88 | + "label" => Self::Label, | ||
| 89 | + "link" => Self::Link, | ||
| 90 | + "title" => Self::Title, | ||
| 91 | + "title-2" => Self::Title2, | ||
| 92 | + "dim-label" => Self::DimLabel, | ||
| 93 | + "button" => Self::Button, | ||
| 94 | + "checkbutton" | "checkbox" => Self::CheckButton, | ||
| 95 | + "entry" => Self::Entry, | ||
| 96 | + "separator" => Self::Separator, | ||
| 97 | + "spacer" | "gap" => Self::Spacer, | ||
| 98 | + "progress" => Self::Progress, | ||
| 99 | + "spinner" => Self::Spinner, | ||
| 100 | + "image" => Self::Image, | ||
| 101 | + "avatar" => Self::Avatar, | ||
| 102 | + "reaction" => Self::Reaction, | ||
| 103 | + "status" => Self::Status, | ||
| 104 | + other => Self::Unknown(other.to_owned()), | ||
| 105 | + } | ||
| 106 | + } | ||
| 107 | + | ||
| 108 | + /// The canonical name of a parsed tag: `:hbox` and `:vbox` both answer | ||
| 109 | + /// `box`, since the orientation lives in a prop rather than in the tag. | ||
| 110 | + fn name(&self) -> &str { | ||
| 111 | + match self { | ||
| 112 | + Self::Window => "window", | ||
| 113 | + Self::Box => "box", | ||
| 114 | + Self::Page => "page", | ||
| 115 | + Self::Card => "card", | ||
| 116 | + Self::Frame => "frame", | ||
| 117 | + Self::Scroll => "scroll", | ||
| 118 | + Self::Label => "label", | ||
| 119 | + Self::Link => "link", | ||
| 120 | + Self::Title => "title", | ||
| 121 | + Self::Title2 => "title-2", | ||
| 122 | + Self::DimLabel => "dim-label", | ||
| 123 | + Self::Button => "button", | ||
| 124 | + Self::CheckButton => "checkbutton", | ||
| 125 | + Self::Entry => "entry", | ||
| 126 | + Self::Separator => "separator", | ||
| 127 | + Self::Spacer => "spacer", | ||
| 128 | + Self::Progress => "progress", | ||
| 129 | + Self::Spinner => "spinner", | ||
| 130 | + Self::Image => "image", | ||
| 131 | + Self::Avatar => "avatar", | ||
| 132 | + Self::Reaction => "reaction", | ||
| 133 | + Self::Status => "status", | ||
| 134 | + Self::Unknown(name) => name, | ||
| 135 | + } | ||
| 136 | + } | ||
| 137 | +} | ||
| 138 | + | ||
| 139 | +/// One interaction, waiting to be drained by the caller. | ||
| 140 | +/// | ||
| 141 | +/// Names match glimmer's handler props with the `on-` dropped: `click` pairs | ||
| 142 | +/// with `:on-click`, `change` with `:on-change`, and so on. `text` and `num` | ||
| 143 | +/// carry the payload the handler is called with, empty when it takes none. | ||
| 144 | +#[derive(Clone, Debug, PartialEq)] | ||
| 145 | +pub struct Event { | ||
| 146 | + pub node: u32, | ||
| 147 | + pub name: &'static str, | ||
| 148 | + pub text: String, | ||
| 149 | + pub num: f64, | ||
| 150 | +} | ||
| 151 | + | ||
| 152 | +/// One prop value as EDN. Numbers that happen to be whole print without a | ||
| 153 | +/// trailing `.0`, since every number crossed the boundary as a double and | ||
| 154 | +/// `{:spacing 8.0}` reads worse than `{:spacing 8}`. | ||
| 155 | +fn write_value(value: &Value, out: &mut String) { | ||
| 156 | + match value { | ||
| 157 | + Value::Bool(b) => out.push_str(if *b { "true" } else { "false" }), | ||
| 158 | + Value::Num(n) => { | ||
| 159 | + if n.is_finite() && n.fract() == 0.0 && n.abs() < 1e15 { | ||
| 160 | + out.push_str(&format!("{}", *n as i64)); | ||
| 161 | + } else if n.is_finite() { | ||
| 162 | + out.push_str(&format!("{n}")); | ||
| 163 | + } else { | ||
| 164 | + // EDN has no infinity or NaN literal; say so rather than emit | ||
| 165 | + // something no reader will take. | ||
| 166 | + out.push_str("nil"); | ||
| 167 | + } | ||
| 168 | + } | ||
| 169 | + Value::Str(text) => { | ||
| 170 | + out.push('"'); | ||
| 171 | + for c in text.chars() { | ||
| 172 | + match c { | ||
| 173 | + '"' => out.push_str("\\\""), | ||
| 174 | + '\\' => out.push_str("\\\\"), | ||
| 175 | + '\n' => out.push_str("\\n"), | ||
| 176 | + '\r' => out.push_str("\\r"), | ||
| 177 | + '\t' => out.push_str("\\t"), | ||
| 178 | + _ => out.push(c), | ||
| 179 | + } | ||
| 180 | + } | ||
| 181 | + out.push('"'); | ||
| 182 | + } | ||
| 183 | + } | ||
| 184 | +} | ||
| 185 | + | ||
| 186 | +#[derive(Clone, Debug, Default)] | ||
| 187 | +struct Node { | ||
| 188 | + tag: Tag, | ||
| 189 | + props: HashMap<String, Value>, | ||
| 190 | + children: Vec<u32>, | ||
| 191 | + /// 0 when unparented. The root's parent is 0 too, which is what stops the | ||
| 192 | + /// ancestor walk in [`Tree::would_cycle`]. | ||
| 193 | + parent: u32, | ||
| 194 | +} | ||
| 195 | + | ||
| 196 | +impl Default for Tag { | ||
| 197 | + fn default() -> Self { | ||
| 198 | + Self::Unknown(String::new()) | ||
| 199 | + } | ||
| 200 | +} | ||
| 201 | + | ||
| 202 | +/// One named source of live pixels: what has arrived, and what is on the GPU. | ||
| 203 | +#[derive(Default)] | ||
| 204 | +struct Feed { | ||
| 205 | + /// Pixels written since the last paint, waiting to be uploaded. Taken (not | ||
| 206 | + /// copied) by the paint that consumes them. | ||
| 207 | + pending: Option<egui::ColorImage>, | ||
| 208 | + /// The texture the last upload produced. Kept when nothing new arrives, so | ||
| 209 | + /// a still source keeps painting instead of blinking out between frames. | ||
| 210 | + texture: Option<egui::TextureHandle>, | ||
| 211 | +} | ||
| 212 | + | ||
| 213 | +/// The node arena. | ||
| 214 | +/// | ||
| 215 | +/// Handles are `index + 1`, so 0 is always "no node" — the value C gets back | ||
| 216 | +/// from a failed allocation and the sibling argument that means "first". | ||
| 217 | +/// Freed slots are reused, so a list that churns rows does not grow the arena. | ||
| 218 | +pub struct Tree { | ||
| 219 | + nodes: Vec<Option<Node>>, | ||
| 220 | + free: Vec<u32>, | ||
| 221 | + root: u32, | ||
| 222 | + /// Decoded images, by the path they came from. An `:image` node is walked | ||
| 223 | + /// every frame and must not decode a file every time. | ||
| 224 | + textures: HashMap<String, Option<egui::TextureHandle>>, | ||
| 225 | + /// Live pixels pushed in by name, for an `:image` with a `feed` rather | ||
| 226 | + /// than a `src`. A caller that has frames of its own — a camera, a video | ||
| 227 | + /// decoder, a renderer — writes them here and the tag paints the latest. | ||
| 228 | + /// | ||
| 229 | + /// Two halves, because the writer is not in a frame and the uploader is: | ||
| 230 | + /// `pending` is what arrived since the last paint, `texture` is what was | ||
| 231 | + /// uploaded from it. A frame that arrives twice between paints overwrites | ||
| 232 | + /// the first, so a 30fps source cannot outrun a 60fps window into a queue. | ||
| 233 | + feeds: HashMap<String, Feed>, | ||
| 234 | + pending: VecDeque<Event>, | ||
| 235 | + /// The event most recently dequeued by `poll`, whose fields the accessors | ||
| 236 | + /// read. Held here so the ABI can return a payload without out-parameters. | ||
| 237 | + current: Option<Event>, | ||
| 238 | +} | ||
| 239 | + | ||
| 240 | +/// A stable colour for a name: the same person is the same colour every time, | ||
| 241 | +/// and two people are unlikely to share one. Kept dark enough for the light | ||
| 242 | +/// text drawn on top and dull enough not to compete with the accent. | ||
| 243 | +fn name_colour(name: &str, theme: &Theme) -> Color32 { | ||
| 244 | + let mut hash: u32 = 2166136261; | ||
| 245 | + for b in name.as_bytes() { | ||
| 246 | + hash ^= *b as u32; | ||
| 247 | + hash = hash.wrapping_mul(16777619); | ||
| 248 | + } | ||
| 249 | + // Six hues around the wheel, at a fixed saturation and value, rather than | ||
| 250 | + // free RGB: random channels give muddy colours as often as good ones. | ||
| 251 | + let sector = (hash % 6) as f32; | ||
| 252 | + let (r, g, b) = match sector as u32 { | ||
| 253 | + 0 => (0.80, 0.35, 0.35), | ||
| 254 | + 1 => (0.80, 0.55, 0.25), | ||
| 255 | + 2 => (0.45, 0.65, 0.35), | ||
| 256 | + 3 => (0.30, 0.60, 0.65), | ||
| 257 | + 4 => (0.40, 0.50, 0.80), | ||
| 258 | + _ => (0.65, 0.40, 0.70), | ||
| 259 | + }; | ||
| 260 | + let _ = theme; | ||
| 261 | + Color32::from_rgb((r * 255.0) as u8, (g * 255.0) as u8, (b * 255.0) as u8) | ||
| 262 | +} | ||
| 263 | + | ||
| 264 | +impl Tree { | ||
| 265 | + /// Whether anything under `id` has `:scroll-here` set this frame. | ||
| 266 | + /// | ||
| 267 | + /// Walked rather than remembered: the prop is set for the moment of a jump | ||
| 268 | + /// and taken off again, so there is nothing to keep, and this runs once | ||
| 269 | + /// per scroll area rather than once per node. | ||
| 270 | + fn wants_scroll_to(&self, id: u32) -> bool { | ||
| 271 | + let Some(node) = self.slot(id) else { | ||
| 272 | + return false; | ||
| 273 | + }; | ||
| 274 | + matches!(node.props.get("scroll-here"), Some(Value::Bool(true))) | ||
| 275 | + || node | ||
| 276 | + .children | ||
| 277 | + .iter() | ||
| 278 | + .any(|child| self.wants_scroll_to(*child)) | ||
| 279 | + } | ||
| 280 | + | ||
| 281 | + /// The texture for a file, decoding it the first time it is asked for. | ||
| 282 | + /// A file that will not decode is remembered as such, so a bad path costs | ||
| 283 | + /// one failed read rather than one per frame. | ||
| 284 | + /// Hand the tree a frame of live pixels under `key`, to be painted by any | ||
| 285 | + /// `:image` whose `feed` names it. `rgba` is `width * height * 4` bytes, | ||
| 286 | + /// row-major, and is copied — the caller keeps ownership and may reuse the | ||
| 287 | + /// buffer the moment this returns. | ||
| 288 | + /// | ||
| 289 | + /// Rejects a frame whose length disagrees with its dimensions rather than | ||
| 290 | + /// painting torn pixels: a capture path that changes resolution mid-stream | ||
| 291 | + /// otherwise reads the tail of the old buffer as the head of the new one. | ||
| 292 | + pub fn set_frame(&mut self, key: &str, width: u32, height: u32, rgba: &[u8]) -> bool { | ||
| 293 | + if key.is_empty() || width == 0 || height == 0 { | ||
| 294 | + return false; | ||
| 295 | + } | ||
| 296 | + let expected = (width as usize) | ||
| 297 | + .saturating_mul(height as usize) | ||
| 298 | + .saturating_mul(4); | ||
| 299 | + if rgba.len() != expected { | ||
| 300 | + return false; | ||
| 301 | + } | ||
| 302 | + let image = egui::ColorImage::from_rgba_unmultiplied( | ||
| 303 | + [width as usize, height as usize], | ||
| 304 | + rgba, | ||
| 305 | + ); | ||
| 306 | + // Overwrites whatever had not been painted yet: the newest frame is | ||
| 307 | + // the only one worth showing, and a backlog of stale ones is latency. | ||
| 308 | + self.feeds.entry(key.to_owned()).or_default().pending = Some(image); | ||
| 309 | + true | ||
| 310 | + } | ||
| 311 | + | ||
| 312 | + /// Forget a feed and release its texture. A call that ends leaves a tile | ||
| 313 | + /// behind otherwise — the last frame of a participant who has gone. | ||
| 314 | + pub fn drop_frame(&mut self, key: &str) -> bool { | ||
| 315 | + self.feeds.remove(key).is_some() | ||
| 316 | + } | ||
| 317 | + | ||
| 318 | + /// The texture for a feed, uploading this paint's pending frame first. | ||
| 319 | + fn feed_texture(&mut self, ui: &Ui, key: &str) -> Option<egui::TextureHandle> { | ||
| 320 | + let feed = self.feeds.get_mut(key)?; | ||
| 321 | + if let Some(image) = feed.pending.take() { | ||
| 322 | + match feed.texture.as_mut() { | ||
| 323 | + // `set` reuses the allocation when the size is unchanged, | ||
| 324 | + // which is the whole point at video rates. | ||
| 325 | + Some(texture) => texture.set(image, TextureOptions::LINEAR), | ||
| 326 | + None => { | ||
| 327 | + feed.texture = Some(ui.ctx().load_texture( | ||
| 328 | + format!("vidya/tree/feed/{key}"), | ||
| 329 | + image, | ||
| 330 | + TextureOptions::LINEAR, | ||
| 331 | + )) | ||
| 332 | + } | ||
| 333 | + } | ||
| 334 | + } | ||
| 335 | + feed.texture.clone() | ||
| 336 | + } | ||
| 337 | + | ||
| 338 | + fn texture(&mut self, ui: &Ui, path: &str) -> Option<egui::TextureHandle> { | ||
| 339 | + if let Some(cached) = self.textures.get(path) { | ||
| 340 | + return cached.clone(); | ||
| 341 | + } | ||
| 342 | + let handle = std::fs::read(path) | ||
| 343 | + .ok() | ||
| 344 | + .and_then(|bytes| decode_png_rgba(&bytes)) | ||
| 345 | + .map(|image| { | ||
| 346 | + ui.ctx() | ||
| 347 | + .load_texture(format!("vidya/tree/{path}"), image, TextureOptions::LINEAR) | ||
| 348 | + }); | ||
| 349 | + self.textures.insert(path.to_owned(), handle.clone()); | ||
| 350 | + handle | ||
| 351 | + } | ||
| 352 | +} | ||
| 353 | + | ||
| 354 | +/// PNG bytes as an egui image. PNG alone: it is what the vendored decoder | ||
| 355 | +/// reads, and what the media this paints is served as. | ||
| 356 | +fn decode_png_rgba(bytes: &[u8]) -> Option<egui::ColorImage> { | ||
| 357 | + let mut decoder = png::Decoder::new(std::io::Cursor::new(bytes)); | ||
| 358 | + decoder.set_transformations(png::Transformations::EXPAND | png::Transformations::ALPHA); | ||
| 359 | + let mut reader = decoder.read_info().ok()?; | ||
| 360 | + let mut buf = vec![0; reader.output_buffer_size()]; | ||
| 361 | + let info = reader.next_frame(&mut buf).ok()?; | ||
| 362 | + let (w, h) = (info.width as usize, info.height as usize); | ||
| 363 | + let raw = &buf[..info.buffer_size()]; | ||
| 364 | + let rgba: Vec<u8> = match info.color_type { | ||
| 365 | + png::ColorType::Rgba => raw.to_vec(), | ||
| 366 | + png::ColorType::Rgb => raw | ||
| 367 | + .chunks_exact(3) | ||
| 368 | + .flat_map(|c| [c[0], c[1], c[2], 255]) | ||
| 369 | + .collect(), | ||
| 370 | + _ => return None, | ||
| 371 | + }; | ||
| 372 | + (rgba.len() == w * h * 4).then(|| egui::ColorImage::from_rgba_unmultiplied([w, h], &rgba)) | ||
| 373 | +} | ||
| 374 | + | ||
| 375 | +impl Default for Tree { | ||
| 376 | + fn default() -> Self { | ||
| 377 | + let mut tree = Self { | ||
| 378 | + nodes: Vec::new(), | ||
| 379 | + free: Vec::new(), | ||
| 380 | + root: 0, | ||
| 381 | + textures: HashMap::new(), | ||
| 382 | + feeds: HashMap::new(), | ||
| 383 | + pending: VecDeque::new(), | ||
| 384 | + current: None, | ||
| 385 | + }; | ||
| 386 | + tree.root = tree.new_node("window"); | ||
| 387 | + tree | ||
| 388 | + } | ||
| 389 | +} | ||
| 390 | + | ||
| 391 | +impl Tree { | ||
| 392 | + pub fn root(&self) -> u32 { | ||
| 393 | + self.root | ||
| 394 | + } | ||
| 395 | + | ||
| 396 | + fn slot(&self, id: u32) -> Option<&Node> { | ||
| 397 | + if id == 0 { | ||
| 398 | + return None; | ||
| 399 | + } | ||
| 400 | + self.nodes.get(id as usize - 1).and_then(Option::as_ref) | ||
| 401 | + } | ||
| 402 | + | ||
| 403 | + fn slot_mut(&mut self, id: u32) -> Option<&mut Node> { | ||
| 404 | + if id == 0 { | ||
| 405 | + return None; | ||
| 406 | + } | ||
| 407 | + self.nodes.get_mut(id as usize - 1).and_then(Option::as_mut) | ||
| 408 | + } | ||
| 409 | + | ||
| 410 | + pub fn exists(&self, id: u32) -> bool { | ||
| 411 | + self.slot(id).is_some() | ||
| 412 | + } | ||
| 413 | + | ||
| 414 | + // ── editing ───────────────────────────────────────────────────────────── | ||
| 415 | + | ||
| 416 | + pub fn new_node(&mut self, tag: &str) -> u32 { | ||
| 417 | + let node = Node { | ||
| 418 | + tag: Tag::parse(tag), | ||
| 419 | + ..Node::default() | ||
| 420 | + }; | ||
| 421 | + match self.free.pop() { | ||
| 422 | + Some(id) => { | ||
| 423 | + self.nodes[id as usize - 1] = Some(node); | ||
| 424 | + id | ||
| 425 | + } | ||
| 426 | + None => { | ||
| 427 | + self.nodes.push(Some(node)); | ||
| 428 | + self.nodes.len() as u32 | ||
| 429 | + } | ||
| 430 | + } | ||
| 431 | + } | ||
| 432 | + | ||
| 433 | + /// Drop `id` and everything under it, unparenting it first. | ||
| 434 | + /// | ||
| 435 | + /// glimmer has no separate destroy operation — `remove-child!` is the last | ||
| 436 | + /// the reconciler ever says about a widget — so removal frees, and a node | ||
| 437 | + /// handle the caller still holds after that is simply dead. | ||
| 438 | + pub fn free_node(&mut self, id: u32) { | ||
| 439 | + let parent = match self.slot(id) { | ||
| 440 | + Some(n) => n.parent, | ||
| 441 | + None => return, | ||
| 442 | + }; | ||
| 443 | + self.detach(parent, id); | ||
| 444 | + self.free_subtree(id); | ||
| 445 | + } | ||
| 446 | + | ||
| 447 | + fn free_subtree(&mut self, id: u32) { | ||
| 448 | + let Some(node) = self.slot_mut(id).map(std::mem::take) else { | ||
| 449 | + return; | ||
| 450 | + }; | ||
| 451 | + self.nodes[id as usize - 1] = None; | ||
| 452 | + self.free.push(id); | ||
| 453 | + for child in node.children { | ||
| 454 | + self.free_subtree(child); | ||
| 455 | + } | ||
| 456 | + // An event queued against a node that has since been removed would be | ||
| 457 | + // routed to a handler the caller has already forgotten. | ||
| 458 | + self.pending.retain(|e| e.node != id); | ||
| 459 | + } | ||
| 460 | + | ||
| 461 | + /// Unparent `child` without freeing it. `parent` may be 0 (already loose). | ||
| 462 | + fn detach(&mut self, parent: u32, child: u32) { | ||
| 463 | + if let Some(p) = self.slot_mut(parent) { | ||
| 464 | + p.children.retain(|&c| c != child); | ||
| 465 | + } | ||
| 466 | + if let Some(c) = self.slot_mut(child) { | ||
| 467 | + c.parent = 0; | ||
| 468 | + } | ||
| 469 | + } | ||
| 470 | + | ||
| 471 | + /// True when parenting `child` under `parent` would make a loop — `child` | ||
| 472 | + /// is `parent`, or an ancestor of it. A cycle here is an infinite paint, | ||
| 473 | + /// so it is checked rather than trusted. | ||
| 474 | + fn would_cycle(&self, parent: u32, child: u32) -> bool { | ||
| 475 | + let mut at = parent; | ||
| 476 | + while at != 0 { | ||
| 477 | + if at == child { | ||
| 478 | + return true; | ||
| 479 | + } | ||
| 480 | + at = match self.slot(at) { | ||
| 481 | + Some(n) => n.parent, | ||
| 482 | + None => 0, | ||
| 483 | + }; | ||
| 484 | + } | ||
| 485 | + false | ||
| 486 | + } | ||
| 487 | + | ||
| 488 | + pub fn append(&mut self, parent: u32, child: u32) -> bool { | ||
| 489 | + self.insert_at(parent, child, usize::MAX) | ||
| 490 | + } | ||
| 491 | + | ||
| 492 | + fn insert_at(&mut self, parent: u32, child: u32, index: usize) -> bool { | ||
| 493 | + if parent == 0 || child == 0 || !self.exists(parent) || !self.exists(child) { | ||
| 494 | + return false; | ||
| 495 | + } | ||
| 496 | + if self.would_cycle(parent, child) { | ||
| 497 | + return false; | ||
| 498 | + } | ||
| 499 | + // Moving a child that already has a parent (including this one) is a | ||
| 500 | + // reparent, not a duplicate: take it out first so it appears once. | ||
| 501 | + let old_parent = self.slot(child).map_or(0, |n| n.parent); | ||
| 502 | + self.detach(old_parent, child); | ||
| 503 | + | ||
| 504 | + let p = self.slot_mut(parent).expect("checked above"); | ||
| 505 | + let at = index.min(p.children.len()); | ||
| 506 | + p.children.insert(at, child); | ||
| 507 | + self.slot_mut(child).expect("checked above").parent = parent; | ||
| 508 | + true | ||
| 509 | + } | ||
| 510 | + | ||
| 511 | + pub fn remove(&mut self, parent: u32, child: u32) { | ||
| 512 | + if self.slot(child).map_or(true, |n| n.parent != parent) { | ||
| 513 | + return; | ||
| 514 | + } | ||
| 515 | + self.free_node(child); | ||
| 516 | + } | ||
| 517 | + | ||
| 518 | + /// Move `child` to sit immediately after `sibling`; `sibling` 0 means first. | ||
| 519 | + /// glimmer's keyed reconciliation calls this to reorder a list without | ||
| 520 | + /// rebuilding the widgets in it. | ||
| 521 | + pub fn insert_after(&mut self, parent: u32, child: u32, sibling: u32) -> bool { | ||
| 522 | + if !self.exists(parent) || !self.exists(child) { | ||
| 523 | + return false; | ||
| 524 | + } | ||
| 525 | + let index = if sibling == 0 { | ||
| 526 | + 0 | ||
| 527 | + } else { | ||
| 528 | + match self | ||
| 529 | + .slot(parent) | ||
| 530 | + .and_then(|p| p.children.iter().position(|&c| c == sibling)) | ||
| 531 | + { | ||
| 532 | + // The sibling's own slot, once `child` is out of the way, is | ||
| 533 | + // the position after it. | ||
| 534 | + Some(i) => i + 1, | ||
| 535 | + None => return false, | ||
| 536 | + } | ||
| 537 | + }; | ||
| 538 | + // Re-derive the index after detaching: removing `child` from earlier in | ||
| 539 | + // the list shifts everything after it down one. | ||
| 540 | + let before = self | ||
| 541 | + .slot(parent) | ||
| 542 | + .and_then(|p| p.children.iter().position(|&c| c == child)) | ||
| 543 | + .map_or(false, |i| i < index); | ||
| 544 | + self.insert_at(parent, child, if before { index - 1 } else { index }) | ||
| 545 | + } | ||
| 546 | + | ||
| 547 | + pub fn replace(&mut self, parent: u32, old: u32, new: u32) -> bool { | ||
| 548 | + let Some(index) = self | ||
| 549 | + .slot(parent) | ||
| 550 | + .and_then(|p| p.children.iter().position(|&c| c == old)) | ||
| 551 | + else { | ||
| 552 | + return false; | ||
| 553 | + }; | ||
| 554 | + if !self.insert_at(parent, new, index) { | ||
| 555 | + return false; | ||
| 556 | + } | ||
| 557 | + self.remove(parent, old); | ||
| 558 | + true | ||
| 559 | + } | ||
| 560 | + | ||
| 561 | + /// The canonical tag name, or the empty string for a node that is not | ||
| 562 | + /// there. With [`Tree::child_count`] and [`Tree::child_at`] this is enough | ||
| 563 | + /// for a caller to read back the tree it built — which is how the jolt | ||
| 564 | + /// backend's tests assert against a real reconcile with no window open. | ||
| 565 | + pub fn tag_name(&self, id: u32) -> &str { | ||
| 566 | + self.slot(id).map_or("", |n| n.tag.name()) | ||
| 567 | + } | ||
| 568 | + | ||
| 569 | + pub fn child_count(&self, id: u32) -> usize { | ||
| 570 | + self.slot(id).map_or(0, |n| n.children.len()) | ||
| 571 | + } | ||
| 572 | + | ||
| 573 | + pub fn child_at(&self, id: u32, index: usize) -> u32 { | ||
| 574 | + self.slot(id) | ||
| 575 | + .and_then(|n| n.children.get(index)) | ||
| 576 | + .copied() | ||
| 577 | + .unwrap_or(0) | ||
| 578 | + } | ||
| 579 | + | ||
| 580 | + // ── props ─────────────────────────────────────────────────────────────── | ||
| 581 | + | ||
| 582 | + pub fn set(&mut self, id: u32, key: &str, value: Value) { | ||
| 583 | + if let Some(node) = self.slot_mut(id) { | ||
| 584 | + node.props.insert(key.to_owned(), value); | ||
| 585 | + } | ||
| 586 | + } | ||
| 587 | + | ||
| 588 | + pub fn clear_props(&mut self, id: u32) { | ||
| 589 | + if let Some(node) = self.slot_mut(id) { | ||
| 590 | + node.props.clear(); | ||
| 591 | + } | ||
| 592 | + } | ||
| 593 | + | ||
| 594 | + pub fn get(&self, id: u32, key: &str) -> Option<&Value> { | ||
| 595 | + self.slot(id).and_then(|n| n.props.get(key)) | ||
| 596 | + } | ||
| 597 | + | ||
| 598 | + // ── reading it back as hiccup ─────────────────────────────────────────── | ||
| 599 | + | ||
| 600 | + /// The subtree at `id` as pretty-printed hiccup, in the same shape the | ||
| 601 | + /// caller wrote: `[:tag {props} children…]`, one node to a line. | ||
| 602 | + /// | ||
| 603 | + /// This is what the tree *is*, not what a component said — it is read from | ||
| 604 | + /// the arena after the reconciler has had its way with it, so a patch that | ||
| 605 | + /// went to the wrong node shows up here as a difference from the source. | ||
| 606 | + /// | ||
| 607 | + /// A node that does not exist dumps as `nil`. `:hbox` and `:vbox` both | ||
| 608 | + /// dump as `:box`, as they are both stored as one; their orientation is in | ||
| 609 | + /// the props. Handlers are not here — they never crossed the boundary. | ||
| 610 | + pub fn dump(&self, id: u32) -> String { | ||
| 611 | + let mut out = String::new(); | ||
| 612 | + self.dump_into(id, 0, &mut out); | ||
| 613 | + out | ||
| 614 | + } | ||
| 615 | + | ||
| 616 | + fn dump_into(&self, id: u32, depth: usize, out: &mut String) { | ||
| 617 | + let Some(node) = self.slot(id) else { | ||
| 618 | + out.push_str("nil"); | ||
| 619 | + return; | ||
| 620 | + }; | ||
| 621 | + let indent = " ".repeat(depth); | ||
| 622 | + out.push_str("[:"); | ||
| 623 | + out.push_str(node.tag.name()); | ||
| 624 | + | ||
| 625 | + // Sorted, so two dumps of the same tree compare as text. | ||
| 626 | + let mut keys: Vec<&String> = node.props.keys().collect(); | ||
| 627 | + keys.sort(); | ||
| 628 | + out.push_str(" {"); | ||
| 629 | + for (i, key) in keys.iter().enumerate() { | ||
| 630 | + if i > 0 { | ||
| 631 | + out.push(' '); | ||
| 632 | + } | ||
| 633 | + out.push(':'); | ||
| 634 | + out.push_str(key); | ||
| 635 | + out.push(' '); | ||
| 636 | + write_value(&node.props[*key], out); | ||
| 637 | + } | ||
| 638 | + out.push('}'); | ||
| 639 | + | ||
| 640 | + for child in &node.children { | ||
| 641 | + out.push('\n'); | ||
| 642 | + out.push_str(&indent); | ||
| 643 | + out.push_str(" "); | ||
| 644 | + self.dump_into(*child, depth + 1, out); | ||
| 645 | + } | ||
| 646 | + out.push(']'); | ||
| 647 | + } | ||
| 648 | + | ||
| 649 | + // ── events ────────────────────────────────────────────────────────────── | ||
| 650 | + | ||
| 651 | + fn emit(&mut self, node: u32, name: &'static str, text: String, num: f64) { | ||
| 652 | + self.pending.push_back(Event { | ||
| 653 | + node, | ||
| 654 | + name, | ||
| 655 | + text, | ||
| 656 | + num, | ||
| 657 | + }); | ||
| 658 | + } | ||
| 659 | + | ||
| 660 | + /// Dequeue one event into the accessor slot. False when the queue is empty. | ||
| 661 | + pub fn poll(&mut self) -> bool { | ||
| 662 | + self.current = self.pending.pop_front(); | ||
| 663 | + self.current.is_some() | ||
| 664 | + } | ||
| 665 | + | ||
| 666 | + pub fn current(&self) -> Option<&Event> { | ||
| 667 | + self.current.as_ref() | ||
| 668 | + } | ||
| 669 | + | ||
| 670 | + // ── painting ──────────────────────────────────────────────────────────── | ||
| 671 | + | ||
| 672 | + /// Emit the whole tree into `ui`. Called once per frame. | ||
| 673 | + pub fn paint(&mut self, ui: &mut Ui, theme: &Theme) { | ||
| 674 | + let root = self.root; | ||
| 675 | + self.paint_node(root, ui, theme); | ||
| 676 | + } | ||
| 677 | + | ||
| 678 | + fn paint_children(&mut self, id: u32, ui: &mut Ui, theme: &Theme) { | ||
| 679 | + // The child list is copied rather than borrowed: painting a child can | ||
| 680 | + // write a prop back (an entry's text) or queue an event, both of which | ||
| 681 | + // need `&mut self` while the walk is in flight. A UI's worth of `u32`s | ||
| 682 | + // is a cheap price for not threading a cell through every widget. | ||
| 683 | + let children = self | ||
| 684 | + .slot(id) | ||
| 685 | + .map(|n| n.children.clone()) | ||
| 686 | + .unwrap_or_default(); | ||
| 687 | + for child in children { | ||
| 688 | + self.paint_node(child, ui, theme); | ||
| 689 | + } | ||
| 690 | + } | ||
| 691 | + | ||
| 692 | + fn paint_node(&mut self, id: u32, ui: &mut Ui, theme: &Theme) { | ||
| 693 | + let Some((tag, props)) = self | ||
| 694 | + .slot(id) | ||
| 695 | + .map(|n| (n.tag.clone(), Props(n.props.clone()))) | ||
| 696 | + else { | ||
| 697 | + return; | ||
| 698 | + }; | ||
| 699 | + let enabled = props.bool("sensitive", true); | ||
| 700 | + // `:scroll-here` brings this node into view in whatever scroll area it | ||
| 701 | + // sits in. It fires on every frame the prop is set, so a caller sets it | ||
| 702 | + // for the moment of a jump and takes it off again — leaving it on would | ||
| 703 | + // pin the area there and take scrolling away from the reader. | ||
| 704 | + let scroll_here = props.bool("scroll-here", false); | ||
| 705 | + let before = ui.cursor().top(); | ||
| 706 | + self.with_width(&props, ui, |tree, ui| { | ||
| 707 | + if enabled { | ||
| 708 | + tree.paint_tag(id, &tag, &props, ui, theme); | ||
| 709 | + } else { | ||
| 710 | + // Scoped rather than per-widget: a dimmed container dims its | ||
| 711 | + // whole subtree, which is what `:sensitive false` means | ||
| 712 | + // everywhere else in glimmer. | ||
| 713 | + ui.add_enabled_ui(false, |ui| tree.paint_tag(id, &tag, &props, ui, theme)); | ||
| 714 | + } | ||
| 715 | + }); | ||
| 716 | + if scroll_here { | ||
| 717 | + // Horizontally the rect is the visible width, not the node's own: | ||
| 718 | + // a rect wider than the viewport is off-screen sideways as far as | ||
| 719 | + // egui is concerned, so it scrolls across to centre it and the | ||
| 720 | + // reader lands on a message with its left edge cut off. Already | ||
| 721 | + // visible on that axis means only the vertical scroll happens. | ||
| 722 | + let clip = ui.clip_rect(); | ||
| 723 | + let rect = egui::Rect::from_min_max( | ||
| 724 | + egui::pos2(clip.left(), before), | ||
| 725 | + egui::pos2(clip.right(), ui.cursor().top()), | ||
| 726 | + ); | ||
| 727 | + ui.scroll_to_rect(rect, Some(Align::Center)); | ||
| 728 | + } | ||
| 729 | + } | ||
| 730 | + | ||
| 731 | + /// Constrain `add` to the node's `:width-request`, when it has one. | ||
| 732 | + /// | ||
| 733 | + /// Immediate mode has no natural width for a field: an entry asks for | ||
| 734 | + /// whatever is left, so an entry beside a button in an `:hbox` takes the | ||
| 735 | + /// row and wraps the button onto the next line. This is how a caller says | ||
| 736 | + /// otherwise. | ||
| 737 | + fn with_width(&mut self, props: &Props, ui: &mut Ui, add: impl FnOnce(&mut Self, &mut Ui)) { | ||
| 738 | + let requested = props.num("width-request", 0.0) as f32; | ||
| 739 | + let fill_height = props.bool("fill-height", false); | ||
| 740 | + if requested <= 0.0 && !fill_height { | ||
| 741 | + add(self, ui); | ||
| 742 | + return; | ||
| 743 | + } | ||
| 744 | + let avail = ui.available_width().max(1.0); | ||
| 745 | + let width = if requested > 0.0 { | ||
| 746 | + requested.min(avail) | ||
| 747 | + } else { | ||
| 748 | + avail | ||
| 749 | + }; | ||
| 750 | + // The height is the row's, not zero: a region allocated with no height | ||
| 751 | + // leaves the row measuring nothing at the moment the next widget is | ||
| 752 | + // placed, so a button beside a text field lands at the row's top edge | ||
| 753 | + // instead of beside it. | ||
| 754 | + // | ||
| 755 | + // A column of a split is the other case. Inside a row, "what is left" | ||
| 756 | + // is the row's own height — one button tall at the moment the column | ||
| 757 | + // is placed — so a pane asking for it is allocated a strip, and the | ||
| 758 | + // scrolling list inside it gets no room. `:fill-height` measures | ||
| 759 | + // against what is visible below the cursor instead, the way `:scroll` | ||
| 760 | + // does: everything from here to the bottom of the window. | ||
| 761 | + let height = if fill_height { | ||
| 762 | + (ui.clip_rect().bottom() - ui.cursor().top()).max(0.0) | ||
| 763 | + } else { | ||
| 764 | + ui.available_height().max(0.0) | ||
| 765 | + }; | ||
| 766 | + ui.allocate_ui_with_layout( | ||
| 767 | + Vec2::new(width, height), | ||
| 768 | + Layout::top_down(Align::Min), | ||
| 769 | + |ui| { | ||
| 770 | + ui.set_min_width(width); | ||
| 771 | + ui.set_max_width(width); | ||
| 772 | + add(self, ui); | ||
| 773 | + }, | ||
| 774 | + ); | ||
| 775 | + } | ||
| 776 | + | ||
| 777 | + fn paint_tag(&mut self, id: u32, tag: &Tag, props: &Props, ui: &mut Ui, theme: &Theme) { | ||
| 778 | + match tag { | ||
| 779 | + // The root is the window itself: its children stack down the page. | ||
| 780 | + // | ||
| 781 | + // Its width is written back onto it, the way an entry writes back | ||
| 782 | + // its text: a caller laying out against the window — one pane on a | ||
| 783 | + // phone, two side by side on a desktop — has no other way to ask | ||
| 784 | + // how much room it has, since nothing else here measures. | ||
| 785 | + Tag::Window => { | ||
| 786 | + let width = ui.available_width().max(0.0) as f64; | ||
| 787 | + self.set(id, "window-width", Value::Num(width)); | ||
| 788 | + self.paint_children(id, ui, theme) | ||
| 789 | + } | ||
| 790 | + | ||
| 791 | + Tag::Box | Tag::Unknown(_) => { | ||
| 792 | + let horizontal = props.str("orientation") == "horizontal"; | ||
| 793 | + let spacing = props.num("spacing", theme.spacing.sm as f64) as f32; | ||
| 794 | + self.with_margin(props, ui, |tree, ui| { | ||
| 795 | + let axis = if horizontal { | ||
| 796 | + Vec2::new(spacing, ui.spacing().item_spacing.y) | ||
| 797 | + } else { | ||
| 798 | + Vec2::new(ui.spacing().item_spacing.x, spacing) | ||
| 799 | + }; | ||
| 800 | + if horizontal { | ||
| 801 | + // `:align :end` lays the row out from the right edge of | ||
| 802 | + // the space it is given, which is how a trailing group | ||
| 803 | + // — an action beside a message, a count beside a name — | ||
| 804 | + // sits against the right of a row rather than trailing | ||
| 805 | + // whatever came before it. | ||
| 806 | + if props.str("align") == "end" { | ||
| 807 | + // Nested in a row of its own: a right-to-left | ||
| 808 | + // layout takes the height available to it, which | ||
| 809 | + // in a column is everything below — every such row | ||
| 810 | + // would be as tall as the rest of the screen, and | ||
| 811 | + // the gaps would land between the rows above it. | ||
| 812 | + ui.horizontal(|ui| { | ||
| 813 | + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { | ||
| 814 | + ui.spacing_mut().item_spacing = axis; | ||
| 815 | + tree.paint_children(id, ui, theme); | ||
| 816 | + }); | ||
| 817 | + }); | ||
| 818 | + } else { | ||
| 819 | + ui.horizontal_wrapped(|ui| { | ||
| 820 | + ui.spacing_mut().item_spacing = axis; | ||
| 821 | + tree.paint_children(id, ui, theme); | ||
| 822 | + }); | ||
| 823 | + } | ||
| 824 | + } else { | ||
| 825 | + // `:align :center` puts a column's children on the | ||
| 826 | + // middle of the width rather than against its left | ||
| 827 | + // edge — what a picture on a screen of its own wants, | ||
| 828 | + // and nothing a column of text ever does. | ||
| 829 | + let cross = if props.str("align") == "center" { | ||
| 830 | + Align::Center | ||
| 831 | + } else { | ||
| 832 | + Align::Min | ||
| 833 | + }; | ||
| 834 | + ui.with_layout(Layout::top_down(cross), |ui| { | ||
| 835 | + ui.spacing_mut().item_spacing = axis; | ||
| 836 | + tree.paint_children(id, ui, theme); | ||
| 837 | + }); | ||
| 838 | + } | ||
| 839 | + }); | ||
| 840 | + } | ||
| 841 | + | ||
| 842 | + // A scrolling column with page padding, optionally centred at a | ||
| 843 | + // maximum width — the shell most Vidya apps put everything inside. | ||
| 844 | + Tag::Page => { | ||
| 845 | + let max_width = props.num("max-width", 0.0) as f32; | ||
| 846 | + let pad = theme.spacing.page; | ||
| 847 | + egui::ScrollArea::vertical() | ||
| 848 | + .auto_shrink([false, false]) | ||
| 849 | + .show(ui, |ui| { | ||
| 850 | + egui::Frame::new() | ||
| 851 | + .inner_margin(Margin::same(pad.clamp(0.0, 127.0) as i8)) | ||
| 852 | + .show(ui, |ui| { | ||
| 853 | + let avail = ui.available_width(); | ||
| 854 | + let width = if max_width > 0.0 { | ||
| 855 | + max_width.min(avail) | ||
| 856 | + } else { | ||
| 857 | + avail | ||
| 858 | + }; | ||
| 859 | + let indent = ((avail - width) * 0.5).max(0.0); | ||
| 860 | + ui.horizontal(|ui| { | ||
| 861 | + ui.add_space(indent); | ||
| 862 | + ui.allocate_ui_with_layout( | ||
| 863 | + Vec2::new(width, 0.0), | ||
| 864 | + Layout::top_down(Align::Min), | ||
| 865 | + |ui| { | ||
| 866 | + ui.set_min_width(width); | ||
| 867 | + ui.set_max_width(width); | ||
| 868 | + vidya_core::vstack(ui, theme, |ui| { | ||
| 869 | + self.paint_children(id, ui, theme); | ||
| 870 | + }); | ||
| 871 | + }, | ||
| 872 | + ); | ||
| 873 | + }); | ||
| 874 | + }); | ||
| 875 | + }); | ||
| 876 | + } | ||
| 877 | + | ||
| 878 | + Tag::Scroll => { | ||
| 879 | + let area = match props.str("orientation") { | ||
| 880 | + "horizontal" => egui::ScrollArea::horizontal(), | ||
| 881 | + "both" => egui::ScrollArea::both(), | ||
| 882 | + _ => egui::ScrollArea::vertical(), | ||
| 883 | + }; | ||
| 884 | + // Without a bound a scroll area takes every point left in its | ||
| 885 | + // parent, so anything after it — a compose bar under a message | ||
| 886 | + // list — is pushed off the bottom. `:max-height` bounds it | ||
| 887 | + // outright; `:reserve` bounds it by what it must leave behind, | ||
| 888 | + // which is what a caller actually knows: the compose bar's | ||
| 889 | + // height, not the window's. | ||
| 890 | + let area = { | ||
| 891 | + let reserve = props.num("reserve", 0.0) as f32; | ||
| 892 | + let max_height = if reserve > 0.0 { | ||
| 893 | + // Clamped against the clip rect as well as the layout's | ||
| 894 | + // own idea of what is left: on Android the two differ | ||
| 895 | + // once the soft keyboard takes the bottom of the | ||
| 896 | + // screen, and it is the visible one that has to win or | ||
| 897 | + // the row below the list is pushed off under the | ||
| 898 | + // keyboard. | ||
| 899 | + let visible = (ui.clip_rect().bottom() - ui.cursor().top()).max(0.0); | ||
| 900 | + (ui.available_height().min(visible) - reserve).max(0.0) | ||
| 901 | + } else { | ||
| 902 | + props.num("max-height", 0.0) as f32 | ||
| 903 | + }; | ||
| 904 | + if max_height > 0.0 { | ||
| 905 | + area.max_height(max_height) | ||
| 906 | + } else { | ||
| 907 | + area | ||
| 908 | + } | ||
| 909 | + }; | ||
| 910 | + // Keyed by the node rather than by where it sits: egui derives | ||
| 911 | + // a scroll area's id from its parent ui, so two areas that | ||
| 912 | + // occupy the same place in the tree at different times — the | ||
| 913 | + // message list and the picture that replaces the screen it is | ||
| 914 | + // on — would otherwise share one offset, and the list would | ||
| 915 | + // come back showing whatever the picture left behind. | ||
| 916 | + // | ||
| 917 | + // `:scroll-key` names an area that outlives its node instead. | ||
| 918 | + // A node id is only as durable as the node: a list unmounted | ||
| 919 | + // while another screen is up comes back as a new node, and a | ||
| 920 | + // position keyed by that is a position thrown away. A caller | ||
| 921 | + // that means "this same list again" says so with a name, and | ||
| 922 | + // the reader returns to the line they left. | ||
| 923 | + let key = { | ||
| 924 | + let name = props.str("scroll-key"); | ||
| 925 | + if name.is_empty() { | ||
| 926 | + Id::new(("vidya_scroll", id)) | ||
| 927 | + } else { | ||
| 928 | + Id::new(("vidya_scroll_key", name)) | ||
| 929 | + } | ||
| 930 | + }; | ||
| 931 | + let area = area.id_salt(key); | ||
| 932 | + // A chat wants the newest line, not the oldest — except on a | ||
| 933 | + // frame where something inside asked to be scrolled to. The | ||
| 934 | + // two are the same control pulling opposite ways, and sticking | ||
| 935 | + // wins every time it is asked, so a jump to an old message | ||
| 936 | + // would land nowhere. | ||
| 937 | + let sticks = props.bool("stick-to-bottom", false) && !self.wants_scroll_to(id); | ||
| 938 | + let area = area.stick_to_bottom(sticks); | ||
| 939 | + // `:scroll-to-bottom` is a number the caller bumps rather than | ||
| 940 | + // a flag it sets: a flag would have to be cleared afterwards, | ||
| 941 | + // and there is no frame in which the caller could do it. A | ||
| 942 | + // value it has not seen before means "now". | ||
| 943 | + let jump_key = key.with("jump"); | ||
| 944 | + let jump = props.num("scroll-to-bottom", 0.0); | ||
| 945 | + let jumped = ui.ctx().data(|d| d.get_temp::<f64>(jump_key)); | ||
| 946 | + let jump_now = jump > 0.0 && jumped != Some(jump); | ||
| 947 | + // The end is last frame's own maximum offset, kept for exactly | ||
| 948 | + // this. Not f32::MAX — egui subtracts the viewport from what it | ||
| 949 | + // is given, and MAX minus anything is still MAX, an offset the | ||
| 950 | + // content can never reach: the area painted nothing and stayed | ||
| 951 | + // that way. Not `scroll_to_rect` either, which a scroll area | ||
| 952 | + // that has been scrolled away from ignores here. | ||
| 953 | + let end_offset_key = key.with("end_offset"); | ||
| 954 | + let area = if jump_now { | ||
| 955 | + ui.ctx().data_mut(|d| d.insert_temp(jump_key, jump)); | ||
| 956 | + let end = ui | ||
| 957 | + .ctx() | ||
| 958 | + .data(|d| d.get_temp::<f32>(end_offset_key)) | ||
| 959 | + .unwrap_or(0.0); | ||
| 960 | + area.vertical_scroll_offset(end) | ||
| 961 | + } else { | ||
| 962 | + area | ||
| 963 | + }; | ||
| 964 | + // Hold the content to the viewport's width, as `:page` does, | ||
| 965 | + // so a wrapping child wraps at the visible edge. | ||
| 966 | + let viewport_width = ui.available_width(); | ||
| 967 | + let output = area.auto_shrink([false, false]).show(ui, |ui| { | ||
| 968 | + ui.set_max_width(viewport_width); | ||
| 969 | + self.paint_children(id, ui, theme); | ||
| 970 | + // The end asked for by scrolling to it, not by setting an | ||
| 971 | + // offset of f32::MAX: egui subtracts the viewport from | ||
| 972 | + // whatever it is given, and MAX minus anything is still | ||
| 973 | + // MAX — an offset the content can never reach, which left | ||
| 974 | + // the area painting nothing at all. | ||
| 975 | + | ||
| 976 | + }); | ||
| 977 | + | ||
| 978 | + // Say when the view leaves the end and when it comes back, so | ||
| 979 | + // a caller can offer the way back. Reported on change only: the | ||
| 980 | + // position itself changes every frame of a scroll, and an event | ||
| 981 | + // a frame is not news. | ||
| 982 | + // Within a line of the end counts as the end, and content | ||
| 983 | + // shorter than the viewport is always at it. | ||
| 984 | + let max_offset = (output.content_size.y - output.inner_rect.height()).max(0.0); | ||
| 985 | + // What `:scroll-to-bottom` will aim at next time it is asked. | ||
| 986 | + ui.ctx() | ||
| 987 | + .data_mut(|d| d.insert_temp(end_offset_key, max_offset)); | ||
| 988 | + let at_end = output.state.offset.y >= max_offset - 24.0; | ||
| 989 | + // Reaching the end is reported at once; leaving it has to hold | ||
| 990 | + // for a few frames first. A burst of arriving messages grows | ||
| 991 | + // the content faster than the offset follows it, and reporting | ||
| 992 | + // that honestly would blink "scrolled away" whenever a channel | ||
| 993 | + // is busy. | ||
| 994 | + let end_key = key.with("at_end"); | ||
| 995 | + let away_key = key.with("away_frames"); | ||
| 996 | + let away_frames = ui.ctx().data(|d| d.get_temp::<u32>(away_key)).unwrap_or(0); | ||
| 997 | + let away_frames = if at_end { 0 } else { away_frames.saturating_add(1) }; | ||
| 998 | + ui.ctx().data_mut(|d| d.insert_temp(away_key, away_frames)); | ||
| 999 | + | ||
| 1000 | + let settled = if at_end { | ||
| 1001 | + Some(true) | ||
| 1002 | + } else if away_frames >= 3 { | ||
| 1003 | + Some(false) | ||
| 1004 | + } else { | ||
| 1005 | + None | ||
| 1006 | + }; | ||
| 1007 | + if let Some(at_end) = settled { | ||
| 1008 | + let was_at_end = ui.ctx().data(|d| d.get_temp::<bool>(end_key)); | ||
| 1009 | + if was_at_end != Some(at_end) { | ||
| 1010 | + ui.ctx().data_mut(|d| d.insert_temp(end_key, at_end)); | ||
| 1011 | + // Only after the first report: the opening one would | ||
| 1012 | + // arrive before the content has a height. | ||
| 1013 | + if was_at_end.is_some() { | ||
| 1014 | + self.emit( | ||
| 1015 | + id, | ||
| 1016 | + "change", | ||
| 1017 | + if at_end { "end" } else { "away" }.to_owned(), | ||
| 1018 | + if at_end { 1.0 } else { 0.0 }, | ||
| 1019 | + ); | ||
| 1020 | + } | ||
| 1021 | + } | ||
| 1022 | + } | ||
| 1023 | + } | ||
| 1024 | + | ||
| 1025 | + Tag::Card => { | ||
| 1026 | + vidya_core::card(ui, theme, |ui| self.paint_children(id, ui, theme)); | ||
| 1027 | + } | ||
| 1028 | + | ||
| 1029 | + // A card with a heading — glimmer-tui's `:frame` label, in the | ||
| 1030 | + // idiom this theme actually has for one. | ||
| 1031 | + Tag::Frame => { | ||
| 1032 | + let label = props.label(); | ||
| 1033 | + vidya_core::card(ui, theme, |ui| { | ||
| 1034 | + if !label.is_empty() { | ||
| 1035 | + vidya_core::title_2(ui, theme, label); | ||
| 1036 | + } | ||
| 1037 | + self.paint_children(id, ui, theme); | ||
| 1038 | + }); | ||
| 1039 | + } | ||
| 1040 | + | ||
| 1041 | + Tag::Label => vidya_core::body(ui, theme, props.label()), | ||
| 1042 | + | ||
| 1043 | + // Body text that answers the pointer: the accent colour and the | ||
| 1044 | + // hand cursor are the whole affordance, and the click is reported | ||
| 1045 | + // like a button's so the caller decides what opening it means. | ||
| 1046 | + Tag::Link => { | ||
| 1047 | + let response = ui | ||
| 1048 | + .add( | ||
| 1049 | + egui::Label::new( | ||
| 1050 | + egui::RichText::new(props.label()) | ||
| 1051 | + .size(theme.type_scale.body) | ||
| 1052 | + .color(theme.palette.accent), | ||
| 1053 | + ) | ||
| 1054 | + .wrap() | ||
| 1055 | + .sense(egui::Sense::click()), | ||
| 1056 | + ) | ||
| 1057 | + .on_hover_cursor(egui::CursorIcon::PointingHand); | ||
| 1058 | + if response.clicked() { | ||
| 1059 | + self.emit(id, "click", props.label().to_owned(), 0.0); | ||
| 1060 | + } | ||
| 1061 | + } | ||
| 1062 | + Tag::Title => vidya_core::title(ui, theme, props.label()), | ||
| 1063 | + Tag::Title2 => vidya_core::title_2(ui, theme, props.label()), | ||
| 1064 | + Tag::DimLabel => vidya_core::dim_label(ui, theme, props.label()), | ||
| 1065 | + | ||
| 1066 | + Tag::Button => { | ||
| 1067 | + let kind = match props.str("kind") { | ||
| 1068 | + "primary" => 1, | ||
| 1069 | + "destructive" => 2, | ||
| 1070 | + _ => 0, | ||
| 1071 | + }; | ||
| 1072 | + if crate::ui::button(ui, theme, props.label(), kind) { | ||
| 1073 | + self.emit(id, "click", String::new(), 0.0); | ||
| 1074 | + } | ||
| 1075 | + } | ||
| 1076 | + | ||
| 1077 | + Tag::CheckButton => { | ||
| 1078 | + let was = props.bool("active", false); | ||
| 1079 | + let (now, changed) = crate::ui::checkbox(ui, theme, was, props.label()); | ||
| 1080 | + if changed { | ||
| 1081 | + // The widget does not own the value: the new state is | ||
| 1082 | + // written back so a component that ignores `:on-toggled` | ||
| 1083 | + // still tracks the click, and the handler decides whether | ||
| 1084 | + // it survives the next render of `:active`. | ||
| 1085 | + self.set(id, "active", Value::Bool(now)); | ||
| 1086 | + self.emit(id, "toggled", String::new(), if now { 1.0 } else { 0.0 }); | ||
| 1087 | + } | ||
| 1088 | + } | ||
| 1089 | + | ||
| 1090 | + Tag::Entry => { | ||
| 1091 | + let mut text = props.str("text").to_owned(); | ||
| 1092 | + let placeholder = props.str("placeholder").to_owned(); | ||
| 1093 | + let rows = props.num("rows", 4.0) as usize; | ||
| 1094 | + let response = if props.bool("multiline", false) { | ||
| 1095 | + vidya_core::text_field_multiline(ui, theme, &mut text, rows.max(1)) | ||
| 1096 | + } else { | ||
| 1097 | + crate::ui::text_field(ui, theme, &mut text, &placeholder) | ||
| 1098 | + }; | ||
| 1099 | + if text != props.str("text") { | ||
| 1100 | + self.set(id, "text", Value::Str(text.clone())); | ||
| 1101 | + self.emit(id, "change", text, 0.0); | ||
| 1102 | + } | ||
| 1103 | + if response.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) { | ||
| 1104 | + self.emit(id, "activate", String::new(), 0.0); | ||
| 1105 | + } | ||
| 1106 | + // A paste of something that is not text. egui turns Ctrl+V | ||
| 1107 | + // into a `Paste` event carrying the clipboard's text, and a | ||
| 1108 | + // clipboard holding a picture has none — so the keystroke | ||
| 1109 | + // arrives as a key press with no paste behind it, and the | ||
| 1110 | + // field would otherwise swallow it. Reported instead, for a | ||
| 1111 | + // caller that has somewhere to put a picture; one that has not | ||
| 1112 | + // ignores it and the keystroke stays as inert as it was. | ||
| 1113 | + // | ||
| 1114 | + // The clipboard is not read here: whether there is a picture | ||
| 1115 | + // on it is answered by `vidya_clipboard_image_png`, and asking | ||
| 1116 | + // twice would copy every pasted image for nothing. | ||
| 1117 | + if response.has_focus() { | ||
| 1118 | + let paste_without_text = ui.input(|i| { | ||
| 1119 | + i.events.iter().any(|e| { | ||
| 1120 | + matches!( | ||
| 1121 | + e, | ||
| 1122 | + egui::Event::Key { | ||
| 1123 | + key: egui::Key::V, | ||
| 1124 | + pressed: true, | ||
| 1125 | + modifiers, | ||
| 1126 | + .. | ||
| 1127 | + } if modifiers.command | ||
| 1128 | + ) | ||
| 1129 | + }) && !i | ||
| 1130 | + .events | ||
| 1131 | + .iter() | ||
| 1132 | + .any(|e| matches!(e, egui::Event::Paste(_))) | ||
| 1133 | + }); | ||
| 1134 | + if paste_without_text { | ||
| 1135 | + self.emit(id, "paste-empty", String::new(), 0.0); | ||
| 1136 | + } | ||
| 1137 | + } | ||
| 1138 | + } | ||
| 1139 | + | ||
| 1140 | + Tag::Separator => crate::ui::separator(ui), | ||
| 1141 | + Tag::Spacer => crate::ui::gap(ui, props.num("size", theme.spacing.md as f64) as f32), | ||
| 1142 | + Tag::Status => crate::ui::status(ui, theme, props.label(), props.bool("live", false)), | ||
| 1143 | + | ||
| 1144 | + Tag::Progress => { | ||
| 1145 | + let value = props.num("value", 0.0) as f32; | ||
| 1146 | + let mut bar = egui::ProgressBar::new(value.clamp(0.0, 1.0)); | ||
| 1147 | + if !props.label().is_empty() { | ||
| 1148 | + bar = bar.text(props.label()); | ||
| 1149 | + } | ||
| 1150 | + ui.add(bar); | ||
| 1151 | + } | ||
| 1152 | + | ||
| 1153 | + // A picture from a file the caller has already fetched. Decoded | ||
| 1154 | + // once and kept as a texture: the tree is walked every frame, and | ||
| 1155 | + // decoding a PNG sixty times a second is not a thing to do. | ||
| 1156 | + // Someone's face, or the next best thing. A chat wants one column | ||
| 1157 | + // of them down the left, so this is a fixed square whatever the | ||
| 1158 | + // picture's own proportions are, and there is always something to | ||
| 1159 | + // draw: a name with no picture behind it becomes its initial on a | ||
| 1160 | + // colour of its own, which keeps the column straight and still | ||
| 1161 | + // tells one person from another at a glance. | ||
| 1162 | + Tag::Avatar => { | ||
| 1163 | + let size = props.num("size", 24.0) as f32; | ||
| 1164 | + let label = props.label().to_owned(); | ||
| 1165 | + let path = props.str("src").to_owned(); | ||
| 1166 | + let (rect, response) = | ||
| 1167 | + ui.allocate_exact_size(Vec2::splat(size), egui::Sense::click()); | ||
| 1168 | + | ||
| 1169 | + let texture = if path.is_empty() { | ||
| 1170 | + None | ||
| 1171 | + } else { | ||
| 1172 | + self.texture(ui, &path) | ||
| 1173 | + }; | ||
| 1174 | + match texture { | ||
| 1175 | + // A corner radius of half the side is a circle. | ||
| 1176 | + Some(texture) => egui::Image::new(egui::load::SizedTexture::new( | ||
| 1177 | + texture.id(), | ||
| 1178 | + Vec2::splat(size), | ||
| 1179 | + )) | ||
| 1180 | + .corner_radius(size * 0.5) | ||
| 1181 | + .paint_at(ui, rect), | ||
| 1182 | + None => { | ||
| 1183 | + let initial = label | ||
| 1184 | + .trim_start_matches(['#', '&', '@', '+', '%', '~']) | ||
| 1185 | + .chars() | ||
| 1186 | + .next() | ||
| 1187 | + .map(|c| c.to_uppercase().to_string()) | ||
| 1188 | + .unwrap_or_else(|| "?".to_owned()); | ||
| 1189 | + ui.painter() | ||
| 1190 | + .circle_filled(rect.center(), size * 0.5, name_colour(&label, theme)); | ||
| 1191 | + ui.painter().text( | ||
| 1192 | + rect.center(), | ||
| 1193 | + Align2::CENTER_CENTER, | ||
| 1194 | + initial, | ||
| 1195 | + FontId::proportional((size * 0.45).max(9.0)), | ||
| 1196 | + theme.palette.accent_fg, | ||
| 1197 | + ); | ||
| 1198 | + } | ||
| 1199 | + } | ||
| 1200 | + if response.clicked() { | ||
| 1201 | + self.emit(id, "click", label, 0.0); | ||
| 1202 | + } | ||
| 1203 | + } | ||
| 1204 | + | ||
| 1205 | + // A reaction chip: the emoji drawn from the Twemoji pack rather | ||
| 1206 | + // than set as text, so it is the colour picture people expect and | ||
| 1207 | + // not a monochrome glyph — or, where the font has no glyph at all, | ||
| 1208 | + // tofu. `:count` rides beside it once more than one person is on | ||
| 1209 | + // it, and `:mine` is what marks the ones you put there yourself. | ||
| 1210 | + Tag::Reaction => { | ||
| 1211 | + let emoji = props.str("emoji").to_owned(); | ||
| 1212 | + let emoji = if emoji.is_empty() { | ||
| 1213 | + props.label().to_owned() | ||
| 1214 | + } else { | ||
| 1215 | + emoji | ||
| 1216 | + }; | ||
| 1217 | + let count = props.num("count", 0.0).max(0.0) as usize; | ||
| 1218 | + let mine = props.bool("mine", false); | ||
| 1219 | + // `:size` is the glyph's, and the pill is sized from it. | ||
| 1220 | + let size = props.num("size", 0.0) as f32; | ||
| 1221 | + let response = if size > 0.0 { | ||
| 1222 | + vidya_core::reaction_chip_sized(ui, theme, &emoji, count, mine, size) | ||
| 1223 | + } else { | ||
| 1224 | + vidya_core::reaction_chip(ui, theme, &emoji, count, mine) | ||
| 1225 | + }; | ||
| 1226 | + if response.clicked() { | ||
| 1227 | + self.emit(id, "click", emoji, count as f64); | ||
| 1228 | + } | ||
| 1229 | + } | ||
| 1230 | + | ||
| 1231 | + Tag::Image => { | ||
| 1232 | + // Two sources, one tag: a `src` is a file decoded once and | ||
| 1233 | + // cached by its path, a `feed` is live pixels pushed in under | ||
| 1234 | + // a name (`vidya_frame_rgba`) and re-uploaded as they arrive. | ||
| 1235 | + // Everything downstream — fit, bounds, the click — is the same | ||
| 1236 | + // for both, which is why this is a prop and not a second tag. | ||
| 1237 | + let feed = props.str("feed").to_owned(); | ||
| 1238 | + let path = props.str("src").to_owned(); | ||
| 1239 | + let max_width = props.num("max-width", 0.0) as f32; | ||
| 1240 | + let texture = if !feed.is_empty() { | ||
| 1241 | + self.feed_texture(ui, &feed) | ||
| 1242 | + } else if !path.is_empty() { | ||
| 1243 | + self.texture(ui, &path) | ||
| 1244 | + } else { | ||
| 1245 | + return; | ||
| 1246 | + }; | ||
| 1247 | + let Some(texture) = texture else { | ||
| 1248 | + // A file that will not decode is not worth a broken-image | ||
| 1249 | + // glyph; the message text beside it already says what it | ||
| 1250 | + // was meant to be. A feed that has had no frame yet is the | ||
| 1251 | + // same: the tile appears when the first one lands. | ||
| 1252 | + return; | ||
| 1253 | + }; | ||
| 1254 | + let size = texture.size_vec2(); | ||
| 1255 | + | ||
| 1256 | + // `:fit` gives the picture every point of the space it has | ||
| 1257 | + // been handed and centres it in it — a picture on a screen of | ||
| 1258 | + // its own, rather than one in a line of chat. It is the one | ||
| 1259 | + // case that scales *up*: a picture opened to be looked at is | ||
| 1260 | + // meant to fill the window, and how big the window is this | ||
| 1261 | + // frame is something only this side knows. Everywhere else the | ||
| 1262 | + // caller's `:max-height` bounds it and nothing is enlarged | ||
| 1263 | + // past its own pixels. | ||
| 1264 | + if props.bool("fit", false) { | ||
| 1265 | + let space = ui.available_size(); | ||
| 1266 | + if space.x <= 0.0 || space.y <= 0.0 || size.x <= 0.0 || size.y <= 0.0 { | ||
| 1267 | + return; | ||
| 1268 | + } | ||
| 1269 | + let scale = (space.x / size.x).min(space.y / size.y); | ||
| 1270 | + let (rect, response) = | ||
| 1271 | + ui.allocate_exact_size(space, egui::Sense::click()); | ||
| 1272 | + let painted = | ||
| 1273 | + egui::Rect::from_center_size(rect.center(), size * scale); | ||
| 1274 | + egui::Image::new(egui::load::SizedTexture::new(texture.id(), size * scale)) | ||
| 1275 | + .paint_at(ui, painted); | ||
| 1276 | + if response.clicked() { | ||
| 1277 | + self.emit(id, "click", String::new(), 0.0); | ||
| 1278 | + } | ||
| 1279 | + return; | ||
| 1280 | + } | ||
| 1281 | + | ||
| 1282 | + let max_height = props.num("max-height", 240.0) as f32; | ||
| 1283 | + let avail = if max_width > 0.0 { | ||
| 1284 | + max_width.min(ui.available_width()) | ||
| 1285 | + } else { | ||
| 1286 | + ui.available_width() | ||
| 1287 | + }; | ||
| 1288 | + let scale = (avail / size.x).min(max_height / size.y).min(1.0); | ||
| 1289 | + // Clickable whether or not the caller listens: the tree does | ||
| 1290 | + // not know which nodes have handlers, and an unheard event | ||
| 1291 | + // costs a queue slot. | ||
| 1292 | + let response = ui | ||
| 1293 | + .add( | ||
| 1294 | + egui::Image::new(egui::load::SizedTexture::new(texture.id(), size * scale)) | ||
| 1295 | + .corner_radius(theme.spacing.radius_sm) | ||
| 1296 | + .sense(egui::Sense::click()), | ||
| 1297 | + ) | ||
| 1298 | + .on_hover_cursor(egui::CursorIcon::PointingHand); | ||
| 1299 | + if response.clicked() { | ||
| 1300 | + self.emit(id, "click", String::new(), 0.0); | ||
| 1301 | + } | ||
| 1302 | + } | ||
| 1303 | + | ||
| 1304 | + Tag::Spinner => { | ||
| 1305 | + ui.horizontal(|ui| { | ||
| 1306 | + ui.add(egui::Spinner::new()); | ||
| 1307 | + if !props.label().is_empty() { | ||
| 1308 | + vidya_core::body(ui, theme, props.label()); | ||
| 1309 | + } | ||
| 1310 | + }); | ||
| 1311 | + } | ||
| 1312 | + } | ||
| 1313 | + } | ||
| 1314 | + | ||
| 1315 | + /// Wrap `add` in the node's `:margin`, when it has one. | ||
| 1316 | + fn with_margin(&mut self, props: &Props, ui: &mut Ui, add: impl FnOnce(&mut Self, &mut Ui)) { | ||
| 1317 | + // `:margin` sets all four sides; `:margin-top` and its siblings say | ||
| 1318 | + // otherwise for one of them. A row that sits at the bottom of a screen | ||
| 1319 | + // wants its space above it, not under it, and that is not a thing a | ||
| 1320 | + // single number can express. | ||
| 1321 | + let side = |key: &str| { | ||
| 1322 | + props.num(key, props.num("margin", 0.0)).clamp(0.0, 127.0) as i8 | ||
| 1323 | + }; | ||
| 1324 | + let margin = Margin { | ||
| 1325 | + left: side("margin-left"), | ||
| 1326 | + right: side("margin-right"), | ||
| 1327 | + top: side("margin-top"), | ||
| 1328 | + bottom: side("margin-bottom"), | ||
| 1329 | + }; | ||
| 1330 | + if margin == Margin::ZERO { | ||
| 1331 | + add(self, ui); | ||
| 1332 | + return; | ||
| 1333 | + } | ||
| 1334 | + egui::Frame::new() | ||
| 1335 | + .inner_margin(margin) | ||
| 1336 | + .show(ui, |ui| add(self, ui)); | ||
| 1337 | + } | ||
| 1338 | +} | ||
| 1339 | + | ||
| 1340 | +/// Typed reads over a node's prop map, with the defaults each widget wants. | ||
| 1341 | +struct Props(HashMap<String, Value>); | ||
| 1342 | + | ||
| 1343 | +impl Props { | ||
| 1344 | + fn str(&self, key: &str) -> &str { | ||
| 1345 | + match self.0.get(key) { | ||
| 1346 | + Some(Value::Str(s)) => s, | ||
| 1347 | + _ => "", | ||
| 1348 | + } | ||
| 1349 | + } | ||
| 1350 | + | ||
| 1351 | + fn num(&self, key: &str, default: f64) -> f64 { | ||
| 1352 | + match self.0.get(key) { | ||
| 1353 | + Some(Value::Num(n)) => *n, | ||
| 1354 | + Some(Value::Bool(b)) => { | ||
| 1355 | + if *b { | ||
| 1356 | + 1.0 | ||
| 1357 | + } else { | ||
| 1358 | + 0.0 | ||
| 1359 | + } | ||
| 1360 | + } | ||
| 1361 | + _ => default, | ||
| 1362 | + } | ||
| 1363 | + } | ||
| 1364 | + | ||
| 1365 | + fn bool(&self, key: &str, default: bool) -> bool { | ||
| 1366 | + match self.0.get(key) { | ||
| 1367 | + Some(Value::Bool(b)) => *b, | ||
| 1368 | + Some(Value::Num(n)) => *n != 0.0, | ||
| 1369 | + _ => default, | ||
| 1370 | + } | ||
| 1371 | + } | ||
| 1372 | + | ||
| 1373 | + /// `:label` is the family's name for a widget's text; `:text` is what a | ||
| 1374 | + /// label is also allowed to use (and what an entry always uses). | ||
| 1375 | + fn label(&self) -> &str { | ||
| 1376 | + let label = self.str("label"); | ||
| 1377 | + if label.is_empty() { | ||
| 1378 | + self.str("text") | ||
| 1379 | + } else { | ||
| 1380 | + label | ||
| 1381 | + } | ||
| 1382 | + } | ||
| 1383 | +} | ||
| 1384 | + | ||
| 1385 | +#[cfg(test)] | ||
| 1386 | +mod tests { | ||
| 1387 | + use super::*; | ||
| 1388 | + | ||
| 1389 | + fn kids(tree: &Tree, id: u32) -> Vec<u32> { | ||
| 1390 | + tree.slot(id) | ||
| 1391 | + .map(|n| n.children.clone()) | ||
| 1392 | + .unwrap_or_default() | ||
| 1393 | + } | ||
| 1394 | + | ||
| 1395 | + #[test] | ||
| 1396 | + fn root_exists_and_is_a_window() { | ||
| 1397 | + let tree = Tree::default(); | ||
| 1398 | + assert!(tree.exists(tree.root())); | ||
| 1399 | + assert_eq!(tree.slot(tree.root()).unwrap().tag, Tag::Window); | ||
| 1400 | + } | ||
| 1401 | + | ||
| 1402 | + #[test] | ||
| 1403 | + fn append_parents_once_even_when_reparenting() { | ||
| 1404 | + let mut tree = Tree::default(); | ||
| 1405 | + let a = tree.new_node("vbox"); | ||
| 1406 | + let b = tree.new_node("hbox"); | ||
| 1407 | + let leaf = tree.new_node("label"); | ||
| 1408 | + tree.append(tree.root(), a); | ||
| 1409 | + tree.append(tree.root(), b); | ||
| 1410 | + | ||
| 1411 | + tree.append(a, leaf); | ||
| 1412 | + tree.append(b, leaf); | ||
| 1413 | + assert_eq!(kids(&tree, a), vec![]); | ||
| 1414 | + assert_eq!(kids(&tree, b), vec![leaf]); | ||
| 1415 | + } | ||
| 1416 | + | ||
| 1417 | + #[test] | ||
| 1418 | + fn a_cycle_is_refused() { | ||
| 1419 | + let mut tree = Tree::default(); | ||
| 1420 | + let outer = tree.new_node("vbox"); | ||
| 1421 | + let inner = tree.new_node("vbox"); | ||
| 1422 | + tree.append(tree.root(), outer); | ||
| 1423 | + tree.append(outer, inner); | ||
| 1424 | + assert!(!tree.append(inner, outer)); | ||
| 1425 | + assert_eq!(kids(&tree, inner), vec![]); | ||
| 1426 | + } | ||
| 1427 | + | ||
| 1428 | + #[test] | ||
| 1429 | + fn remove_frees_the_whole_subtree_and_reuses_slots() { | ||
| 1430 | + let mut tree = Tree::default(); | ||
| 1431 | + let parent = tree.new_node("vbox"); | ||
| 1432 | + let child = tree.new_node("label"); | ||
| 1433 | + tree.append(tree.root(), parent); | ||
| 1434 | + tree.append(parent, child); | ||
| 1435 | + | ||
| 1436 | + tree.remove(tree.root(), parent); | ||
| 1437 | + assert!(!tree.exists(parent)); | ||
| 1438 | + assert!(!tree.exists(child)); | ||
| 1439 | + assert_eq!(tree.new_node("label"), child); | ||
| 1440 | + } | ||
| 1441 | + | ||
| 1442 | + #[test] | ||
| 1443 | + fn remove_ignores_a_child_of_someone_else() { | ||
| 1444 | + let mut tree = Tree::default(); | ||
| 1445 | + let a = tree.new_node("vbox"); | ||
| 1446 | + let b = tree.new_node("vbox"); | ||
| 1447 | + let leaf = tree.new_node("label"); | ||
| 1448 | + tree.append(tree.root(), a); | ||
| 1449 | + tree.append(tree.root(), b); | ||
| 1450 | + tree.append(a, leaf); | ||
| 1451 | + | ||
| 1452 | + tree.remove(b, leaf); | ||
| 1453 | + assert!(tree.exists(leaf)); | ||
| 1454 | + assert_eq!(kids(&tree, a), vec![leaf]); | ||
| 1455 | + } | ||
| 1456 | + | ||
| 1457 | + #[test] | ||
| 1458 | + fn insert_after_reorders_in_both_directions() { | ||
| 1459 | + let mut tree = Tree::default(); | ||
| 1460 | + let parent = tree.new_node("vbox"); | ||
| 1461 | + tree.append(tree.root(), parent); | ||
| 1462 | + let a = tree.new_node("label"); | ||
| 1463 | + let b = tree.new_node("label"); | ||
| 1464 | + let c = tree.new_node("label"); | ||
| 1465 | + for id in [a, b, c] { | ||
| 1466 | + tree.append(parent, id); | ||
| 1467 | + } | ||
| 1468 | + | ||
| 1469 | + // Move a forward, past two siblings. | ||
| 1470 | + assert!(tree.insert_after(parent, a, c)); | ||
| 1471 | + assert_eq!(kids(&tree, parent), vec![b, c, a]); | ||
| 1472 | + // And back to the front. | ||
| 1473 | + assert!(tree.insert_after(parent, a, 0)); | ||
| 1474 | + assert_eq!(kids(&tree, parent), vec![a, b, c]); | ||
| 1475 | + // A no-op move keeps the order it already had. | ||
| 1476 | + assert!(tree.insert_after(parent, b, a)); | ||
| 1477 | + assert_eq!(kids(&tree, parent), vec![a, b, c]); | ||
| 1478 | + } | ||
| 1479 | + | ||
| 1480 | + #[test] | ||
| 1481 | + fn replace_swaps_in_place_and_drops_the_old_node() { | ||
| 1482 | + let mut tree = Tree::default(); | ||
| 1483 | + let parent = tree.new_node("vbox"); | ||
| 1484 | + tree.append(tree.root(), parent); | ||
| 1485 | + let a = tree.new_node("label"); | ||
| 1486 | + let b = tree.new_node("label"); | ||
| 1487 | + let c = tree.new_node("button"); | ||
| 1488 | + tree.append(parent, a); | ||
| 1489 | + tree.append(parent, b); | ||
| 1490 | + | ||
| 1491 | + assert!(tree.replace(parent, a, c)); | ||
| 1492 | + assert_eq!(kids(&tree, parent), vec![c, b]); | ||
| 1493 | + assert!(!tree.exists(a)); | ||
| 1494 | + } | ||
| 1495 | + | ||
| 1496 | + #[test] | ||
| 1497 | + fn props_round_trip_and_clear() { | ||
| 1498 | + let mut tree = Tree::default(); | ||
| 1499 | + let id = tree.new_node("button"); | ||
| 1500 | + tree.set(id, "label", Value::Str("Save".into())); | ||
| 1501 | + tree.set(id, "value", Value::Num(0.5)); | ||
| 1502 | + tree.set(id, "active", Value::Bool(true)); | ||
| 1503 | + assert_eq!(tree.get(id, "label"), Some(&Value::Str("Save".into()))); | ||
| 1504 | + assert_eq!(tree.get(id, "value"), Some(&Value::Num(0.5))); | ||
| 1505 | + assert_eq!(tree.get(id, "active"), Some(&Value::Bool(true))); | ||
| 1506 | + | ||
| 1507 | + tree.clear_props(id); | ||
| 1508 | + assert_eq!(tree.get(id, "label"), None); | ||
| 1509 | + } | ||
| 1510 | + | ||
| 1511 | + #[test] | ||
| 1512 | + fn events_drain_in_order_and_skip_removed_nodes() { | ||
| 1513 | + let mut tree = Tree::default(); | ||
| 1514 | + let a = tree.new_node("button"); | ||
| 1515 | + let b = tree.new_node("button"); | ||
| 1516 | + tree.append(tree.root(), a); | ||
| 1517 | + tree.append(tree.root(), b); | ||
| 1518 | + tree.emit(a, "click", String::new(), 0.0); | ||
| 1519 | + tree.emit(b, "click", String::new(), 0.0); | ||
| 1520 | + | ||
| 1521 | + // Dropping `a` must drop the event still queued against it, or it would | ||
| 1522 | + // be routed to a handler the caller has already forgotten. | ||
| 1523 | + tree.remove(tree.root(), a); | ||
| 1524 | + assert!(tree.poll()); | ||
| 1525 | + assert_eq!(tree.current().unwrap().node, b); | ||
| 1526 | + assert!(!tree.poll()); | ||
| 1527 | + assert!(tree.current().is_none()); | ||
| 1528 | + } | ||
| 1529 | + | ||
| 1530 | + #[test] | ||
| 1531 | + fn unknown_tags_are_kept_as_boxes() { | ||
| 1532 | + let mut tree = Tree::default(); | ||
| 1533 | + let id = tree.new_node("carousel"); | ||
| 1534 | + assert!(tree.exists(id)); | ||
| 1535 | + assert_eq!(tree.slot(id).unwrap().tag, Tag::Unknown("carousel".to_owned())); | ||
| 1536 | + } | ||
| 1537 | + #[test] | ||
| 1538 | + fn dump_is_hiccup_of_what_the_tree_holds() { | ||
| 1539 | + let mut tree = Tree::default(); | ||
| 1540 | + let root = tree.new_node("vbox"); | ||
| 1541 | + tree.set(root, "spacing", Value::Num(8.0)); | ||
| 1542 | + tree.set(root, "orientation", Value::Str("vertical".to_owned())); | ||
| 1543 | + let button = tree.new_node("button"); | ||
| 1544 | + tree.set(button, "label", Value::Str("go".to_owned())); | ||
| 1545 | + tree.set(button, "sensitive", Value::Bool(false)); | ||
| 1546 | + tree.append(root, button); | ||
| 1547 | + | ||
| 1548 | + assert_eq!( | ||
| 1549 | + tree.dump(root), | ||
| 1550 | + "[:box {:orientation \"vertical\" :spacing 8}\n \ | ||
| 1551 | + [:button {:label \"go\" :sensitive false}]]" | ||
| 1552 | + ); | ||
| 1553 | + } | ||
| 1554 | + | ||
| 1555 | + #[test] | ||
| 1556 | + fn dump_keeps_an_unknown_tag_and_escapes_a_string() { | ||
| 1557 | + let mut tree = Tree::default(); | ||
| 1558 | + let id = tree.new_node("carousel"); | ||
| 1559 | + tree.set(id, "label", Value::Str("a \"quote\"\nand a line".to_owned())); | ||
| 1560 | + assert_eq!( | ||
| 1561 | + tree.dump(id), | ||
| 1562 | + "[:carousel {:label \"a \\\"quote\\\"\\nand a line\"}]" | ||
| 1563 | + ); | ||
| 1564 | + assert_eq!(tree.dump(9999), "nil"); | ||
| 1565 | + } | ||
| 1566 | + | ||
| 1567 | + #[test] | ||
| 1568 | + fn a_frame_is_kept_for_the_paint_that_will_upload_it() { | ||
| 1569 | + let mut tree = Tree::default(); | ||
| 1570 | + assert!(tree.set_frame("nandi", 2, 2, &[0u8; 16])); | ||
| 1571 | + assert!(tree.feeds["nandi"].pending.is_some()); | ||
| 1572 | + | ||
| 1573 | + // The newest frame is the only one worth painting: a second one | ||
| 1574 | + // arriving before the first was drawn replaces it rather than queuing. | ||
| 1575 | + assert!(tree.set_frame("nandi", 2, 2, &[7u8; 16])); | ||
| 1576 | + let pending = tree.feeds["nandi"].pending.as_ref().unwrap(); | ||
| 1577 | + assert_eq!(pending.size, [2, 2]); | ||
| 1578 | + assert_eq!(tree.feeds.len(), 1); | ||
| 1579 | + } | ||
| 1580 | + | ||
| 1581 | + #[test] | ||
| 1582 | + fn a_frame_that_does_not_match_its_dimensions_is_refused() { | ||
| 1583 | + let mut tree = Tree::default(); | ||
| 1584 | + // Short of 2x2x4 — a capture path that changed resolution mid-stream | ||
| 1585 | + // would otherwise paint the tail of the old buffer as the new one. | ||
| 1586 | + assert!(!tree.set_frame("nandi", 2, 2, &[0u8; 15])); | ||
| 1587 | + assert!(!tree.set_frame("nandi", 0, 2, &[])); | ||
| 1588 | + assert!(!tree.set_frame("", 2, 2, &[0u8; 16])); | ||
| 1589 | + assert!(tree.feeds.is_empty()); | ||
| 1590 | + } | ||
| 1591 | + | ||
| 1592 | + #[test] | ||
| 1593 | + fn dropping_a_feed_forgets_it() { | ||
| 1594 | + let mut tree = Tree::default(); | ||
| 1595 | + tree.set_frame("nandi", 1, 1, &[0u8; 4]); | ||
| 1596 | + assert!(tree.drop_frame("nandi")); | ||
| 1597 | + assert!(!tree.drop_frame("nandi")); | ||
| 1598 | + assert!(tree.feeds.is_empty()); | ||
| 1599 | + } | ||
| 1600 | +} | ||
added
crates/jolt-vidya/src/ui.rs +333 -0 | new file mode 100644 | ||
| @@ -0,0 +1,333 @@ | ||
| 1 | +//! The immediate-mode cursor: a stack of live [`egui::Ui`] nodes. | |
| 2 | +//! | |
| 3 | +//! The ABI is push/pop (`vidya_card_begin` … `vidya_card_end`) while the Rust | |
| 4 | +//! API is closure-based (`vidya::card(ui, theme, |ui| …)`). Bridging them means | |
| 5 | +//! holding a child `Ui` open across FFI calls, which is exactly what | |
| 6 | +//! [`egui::Frame::begin`] / [`egui::containers::frame::Prepared::end`] and | |
| 7 | +//! [`egui::Ui::new_child`] are for. Every node is closed into its parent, so | |
| 8 | +//! layout ends up identical to the closure form. | |
| 9 | + | |
| 10 | +use egui::containers::frame::Prepared; | |
| 11 | +use egui::{Align, Align2, Color32, FontId, Id, Layout, Rect, Sense, Ui, UiBuilder, Vec2}; | |
| 12 | +use vidya_core::Theme; | |
| 13 | + | |
| 14 | +/// A page taller than its viewport scrolls. `egui::ScrollArea` keeps its | |
| 15 | +/// `begin`/`end` pair private — unlike `egui::Frame`, which is what the card | |
| 16 | +/// node below borrows — so the page reimplements the half we need: offset the | |
| 17 | +/// content, clip it to the viewport, and carry the offset between frames. | |
| 18 | +#[derive(Clone, Copy, Default)] | |
| 19 | +struct ScrollState { | |
| 20 | + /// How far the content is scrolled up, in points. Always >= 0. | |
| 21 | + offset: f32, | |
| 22 | + /// Flick velocity in points per second, decaying under friction. | |
| 23 | + vel: f32, | |
| 24 | + /// Content height measured when the page closed last frame. | |
| 25 | + content: f32, | |
| 26 | +} | |
| 27 | + | |
| 28 | +impl ScrollState { | |
| 29 | + fn load(ctx: &egui::Context, id: Id) -> Self { | |
| 30 | + ctx.data(|d| d.get_temp(id)).unwrap_or_default() | |
| 31 | + } | |
| 32 | + | |
| 33 | + fn store(self, ctx: &egui::Context, id: Id) { | |
| 34 | + ctx.data_mut(|d| d.insert_temp(id, self)); | |
| 35 | + } | |
| 36 | +} | |
| 37 | + | |
| 38 | +/// Pixels per second squared, and the speed below which a flick stops. | |
| 39 | +const SCROLL_FRICTION: f32 = 1000.0; | |
| 40 | +const SCROLL_STOP_SPEED: f32 = 20.0; | |
| 41 | +const SCROLL_BAR_WIDTH: f32 = 6.0; | |
| 42 | + | |
| 43 | +enum Node { | |
| 44 | + /// A plain child region (root, page). | |
| 45 | + Region(Ui), | |
| 46 | + /// A framed surface (card) that paints its background on close. | |
| 47 | + Framed(Box<Prepared>), | |
| 48 | + /// A scrolling page: the content Ui plus what closing it needs. | |
| 49 | + Scrolled { | |
| 50 | + ui: Ui, | |
| 51 | + id: Id, | |
| 52 | + viewport: Rect, | |
| 53 | + state: ScrollState, | |
| 54 | + }, | |
| 55 | +} | |
| 56 | + | |
| 57 | +impl Node { | |
| 58 | + fn ui_mut(&mut self) -> &mut Ui { | |
| 59 | + match self { | |
| 60 | + Self::Region(ui) | Self::Scrolled { ui, .. } => ui, | |
| 61 | + Self::Framed(prepared) => &mut prepared.content_ui, | |
| 62 | + } | |
| 63 | + } | |
| 64 | +} | |
| 65 | + | |
| 66 | +/// Open UI nodes for the current frame. Empty between frames. | |
| 67 | +#[derive(Default)] | |
| 68 | +pub struct Stack { | |
| 69 | + nodes: Vec<Node>, | |
| 70 | +} | |
| 71 | + | |
| 72 | +impl Stack { | |
| 73 | + pub fn is_active(&self) -> bool { | |
| 74 | + !self.nodes.is_empty() | |
| 75 | + } | |
| 76 | + | |
| 77 | + pub fn top(&mut self) -> Option<&mut Ui> { | |
| 78 | + self.nodes.last_mut().map(Node::ui_mut) | |
| 79 | + } | |
| 80 | + | |
| 81 | + pub fn push_root(&mut self, ctx: &egui::Context) { | |
| 82 | + // The screen rect is the whole display: on Android it runs under the | |
| 83 | + // status bar, the nav band and — when it is up — the soft keyboard. | |
| 84 | + // Insetting the root here is what keeps a focused compose field above | |
| 85 | + // the keyboard, and it costs nothing on desktop, where the insets are | |
| 86 | + // zero. | |
| 87 | + let rect = ctx.screen_rect(); | |
| 88 | + #[cfg(target_os = "android")] | |
| 89 | + let rect = { | |
| 90 | + // Not `sync_system_chrome_from_android`: NativeActivity does not | |
| 91 | + // shrink `content_rect` for the soft keyboard, so the measured | |
| 92 | + // insets it installs are the bare bars — and installing them | |
| 93 | + // suppresses the focus-based IME reserve that `system_chrome` falls | |
| 94 | + // back to, which is the one that actually tracks the keyboard here. | |
| 95 | + let chrome = vidya_core::system_chrome(ctx); | |
| 96 | + Rect::from_min_max( | |
| 97 | + egui::pos2(rect.min.x, rect.min.y + chrome.top), | |
| 98 | + egui::pos2(rect.max.x, (rect.max.y - chrome.bottom()).max(rect.min.y)), | |
| 99 | + ) | |
| 100 | + }; | |
| 101 | + let ui = Ui::new( | |
| 102 | + ctx.clone(), | |
| 103 | + Id::new("vidya_ffi_root"), | |
| 104 | + UiBuilder::new() | |
| 105 | + .layer_id(egui::LayerId::background()) | |
| 106 | + .max_rect(rect) | |
| 107 | + .layout(Layout::top_down(Align::Min)), | |
| 108 | + ); | |
| 109 | + self.nodes.push(Node::Region(ui)); | |
| 110 | + } | |
| 111 | + | |
| 112 | + /// Vertical page: page padding, optional centred max width, section gaps. | |
| 113 | + pub fn push_page(&mut self, theme: &Theme, max_width: f32) { | |
| 114 | + let Some(parent) = self.top() else { | |
| 115 | + return; | |
| 116 | + }; | |
| 117 | + let pad = theme.spacing.page; | |
| 118 | + let mut rect = parent.available_rect_before_wrap().shrink(pad); | |
| 119 | + if max_width > 0.0 && rect.width() > max_width { | |
| 120 | + let cx = rect.center().x; | |
| 121 | + rect = Rect::from_min_max( | |
| 122 | + egui::pos2(cx - max_width * 0.5, rect.min.y), | |
| 123 | + egui::pos2(cx + max_width * 0.5, rect.max.y), | |
| 124 | + ); | |
| 125 | + } | |
| 126 | + | |
| 127 | + let ctx = parent.ctx().clone(); | |
| 128 | + let id = Id::new("vidya_ffi_page_scroll"); | |
| 129 | + let mut state = ScrollState::load(&ctx, id); | |
| 130 | + // Last frame's height decides whether this frame scrolls at all: the | |
| 131 | + // content has not been emitted yet, and an immediate-mode page cannot | |
| 132 | + // know its own size in advance. | |
| 133 | + let max_offset = (state.content - rect.height()).max(0.0); | |
| 134 | + | |
| 135 | + if max_offset > 0.0 { | |
| 136 | + // Claim the drag BEFORE any content exists, exactly as egui's own | |
| 137 | + // ScrollArea does — a rect interacted after its children would | |
| 138 | + // steal their presses instead of scrolling past them. | |
| 139 | + let drag = parent.interact(rect, id.with("drag"), Sense::drag()); | |
| 140 | + let dt = parent.input(|i| i.stable_dt).min(0.1); | |
| 141 | + if drag.dragged() { | |
| 142 | + state.offset -= parent.input(|i| i.pointer.delta().y); | |
| 143 | + state.vel = 0.0; | |
| 144 | + } else { | |
| 145 | + if drag.drag_stopped() { | |
| 146 | + state.vel = parent.input(|i| i.pointer.velocity().y); | |
| 147 | + } | |
| 148 | + if state.vel != 0.0 { | |
| 149 | + state.offset -= state.vel * dt; | |
| 150 | + let friction = SCROLL_FRICTION * dt; | |
| 151 | + if friction > state.vel.abs() || state.vel.abs() < SCROLL_STOP_SPEED { | |
| 152 | + state.vel = 0.0; | |
| 153 | + } else { | |
| 154 | + state.vel -= state.vel.signum() * friction; | |
| 155 | + } | |
| 156 | + ctx.request_repaint(); | |
| 157 | + } | |
| 158 | + } | |
| 159 | + if parent.rect_contains_pointer(rect) { | |
| 160 | + state.offset -= parent.input(|i| i.smooth_scroll_delta.y); | |
| 161 | + } | |
| 162 | + } else { | |
| 163 | + state.vel = 0.0; | |
| 164 | + } | |
| 165 | + state.offset = state.offset.clamp(0.0, max_offset); | |
| 166 | + | |
| 167 | + // The content Ui starts above the viewport by the scroll offset and is | |
| 168 | + // free to run as tall as it likes; the clip rect hides the overflow. | |
| 169 | + let content = Rect::from_min_size( | |
| 170 | + rect.min - Vec2::new(0.0, state.offset), | |
| 171 | + egui::vec2(rect.width(), f32::INFINITY), | |
| 172 | + ); | |
| 173 | + let mut ui = parent.new_child( | |
| 174 | + UiBuilder::new() | |
| 175 | + .max_rect(content) | |
| 176 | + .layout(Layout::top_down(Align::Min)), | |
| 177 | + ); | |
| 178 | + ui.set_clip_rect(rect.intersect(parent.clip_rect())); | |
| 179 | + ui.spacing_mut().item_spacing.y = theme.spacing.md; | |
| 180 | + self.nodes.push(Node::Scrolled { | |
| 181 | + ui, | |
| 182 | + id, | |
| 183 | + viewport: rect, | |
| 184 | + state, | |
| 185 | + }); | |
| 186 | + } | |
| 187 | + | |
| 188 | + /// Themed card surface. Mirrors `vidya::card`'s width discipline: content | |
| 189 | + /// fills the parent column and never overflows past it. | |
| 190 | + pub fn push_card(&mut self, theme: &Theme) { | |
| 191 | + let Some(parent) = self.top() else { | |
| 192 | + return; | |
| 193 | + }; | |
| 194 | + let outer = parent.available_width().max(1.0); | |
| 195 | + parent.set_max_width(outer); | |
| 196 | + | |
| 197 | + let mut prepared = theme.card_frame().begin(parent); | |
| 198 | + let inner = prepared.content_ui.available_width().max(1.0); | |
| 199 | + prepared.content_ui.set_min_width(inner); | |
| 200 | + prepared.content_ui.set_max_width(inner); | |
| 201 | + prepared.content_ui.spacing_mut().item_spacing.y = theme.spacing.sm; | |
| 202 | + self.nodes.push(Node::Framed(Box::new(prepared))); | |
| 203 | + } | |
| 204 | + | |
| 205 | + /// Close the innermost node into its parent. The root is kept — only | |
| 206 | + /// [`Self::unwind`] retires it — so a stray `vidya_page_end` cannot leave | |
| 207 | + /// the frame without a cursor. | |
| 208 | + pub fn pop(&mut self) { | |
| 209 | + if self.nodes.len() > 1 { | |
| 210 | + self.close_one(); | |
| 211 | + } | |
| 212 | + } | |
| 213 | + | |
| 214 | + /// Close every open node, innermost first. | |
| 215 | + pub fn unwind(&mut self) { | |
| 216 | + while !self.nodes.is_empty() { | |
| 217 | + self.close_one(); | |
| 218 | + } | |
| 219 | + } | |
| 220 | + | |
| 221 | + fn close_one(&mut self) { | |
| 222 | + let Some(node) = self.nodes.pop() else { | |
| 223 | + return; | |
| 224 | + }; | |
| 225 | + let Some(parent) = self.nodes.last_mut().map(Node::ui_mut) else { | |
| 226 | + return; // Root: nothing to fold into. | |
| 227 | + }; | |
| 228 | + match node { | |
| 229 | + Node::Region(child) => { | |
| 230 | + parent.advance_cursor_after_rect(child.min_rect()); | |
| 231 | + } | |
| 232 | + Node::Scrolled { | |
| 233 | + ui, | |
| 234 | + id, | |
| 235 | + viewport, | |
| 236 | + mut state, | |
| 237 | + } => { | |
| 238 | + state.content = ui.min_rect().height(); | |
| 239 | + let ctx = parent.ctx().clone(); | |
| 240 | + state.store(&ctx, id); | |
| 241 | + paint_scroll_bar(parent, viewport, state); | |
| 242 | + parent.advance_cursor_after_rect(viewport); | |
| 243 | + } | |
| 244 | + Node::Framed(prepared) => { | |
| 245 | + prepared.end(parent); | |
| 246 | + } | |
| 247 | + } | |
| 248 | + } | |
| 249 | +} | |
| 250 | + | |
| 251 | +// ── Leaves ────────────────────────────────────────────────────────────────── | |
| 252 | + | |
| 253 | +pub fn gap(ui: &mut Ui, pixels: f32) { | |
| 254 | + ui.add_space(pixels); | |
| 255 | +} | |
| 256 | + | |
| 257 | +pub fn separator(ui: &mut Ui) { | |
| 258 | + ui.separator(); | |
| 259 | +} | |
| 260 | + | |
| 261 | +pub fn status(ui: &mut Ui, theme: &Theme, label: &str, live: bool) { | |
| 262 | + ui.horizontal(|ui| { | |
| 263 | + vidya_core::status_dot(ui, theme, live); | |
| 264 | + vidya_core::body(ui, theme, label); | |
| 265 | + }); | |
| 266 | +} | |
| 267 | + | |
| 268 | +/// Single-line field over a caller-owned buffer. | |
| 269 | +/// | |
| 270 | +/// The whole [`egui::Response`] is returned rather than just "did it change": | |
| 271 | +/// the tree backend also needs `lost_focus` to tell Enter from a click | |
| 272 | +/// elsewhere. Callers that only want the change flag ask it for `.changed()`. | |
| 273 | +pub fn text_field( | |
| 274 | + ui: &mut Ui, | |
| 275 | + theme: &Theme, | |
| 276 | + text: &mut String, | |
| 277 | + placeholder: &str, | |
| 278 | +) -> egui::Response { | |
| 279 | + let response = vidya_core::text_field_singleline(ui, theme, text); | |
| 280 | + if text.is_empty() && !placeholder.is_empty() { | |
| 281 | + // `text_field_singleline` has no hint-text parameter; paint one in the | |
| 282 | + // field's own padding so the ABI's `placeholder` still means something. | |
| 283 | + let rect = response.rect; | |
| 284 | + ui.painter().text( | |
| 285 | + egui::pos2(rect.left() + theme.spacing.field_pad_x, rect.center().y), | |
| 286 | + Align2::LEFT_CENTER, | |
| 287 | + placeholder, | |
| 288 | + FontId::proportional(theme.type_scale.body), | |
| 289 | + theme.palette.text_secondary, | |
| 290 | + ); | |
| 291 | + } | |
| 292 | + response | |
| 293 | +} | |
| 294 | + | |
| 295 | +/// Checkbox over a caller-owned value. Returns the value after input. | |
| 296 | +pub fn checkbox(ui: &mut Ui, theme: &Theme, checked: bool, label: &str) -> (bool, bool) { | |
| 297 | + let mut value = checked; | |
| 298 | + let response = vidya_core::checkbox(ui, theme, &mut value, label); | |
| 299 | + (value, response.changed()) | |
| 300 | +} | |
| 301 | + | |
| 302 | +/// Button kinds, matching `VidyaButtonKind`. | |
| 303 | +pub fn button(ui: &mut Ui, theme: &Theme, label: &str, kind: i32) -> bool { | |
| 304 | + match kind { | |
| 305 | + 1 => vidya_core::primary_button(ui, theme, label), | |
| 306 | + 2 => vidya_core::destructive_button(ui, theme, label), | |
| 307 | + _ => vidya_core::button(ui, theme, label), | |
| 308 | + } | |
| 309 | + .clicked() | |
| 310 | +} | |
| 311 | + | |
| 312 | +/// A thin overlay bar on the viewport's right edge, drawn only when the page | |
| 313 | +/// actually overflows. egui paints its own bars from private code, so this is | |
| 314 | +/// the one piece of `ScrollArea` that has to be redrawn by hand. | |
| 315 | +fn paint_scroll_bar(ui: &Ui, viewport: Rect, state: ScrollState) { | |
| 316 | + let max_offset = (state.content - viewport.height()).max(0.0); | |
| 317 | + if max_offset <= 0.0 { | |
| 318 | + return; | |
| 319 | + } | |
| 320 | + let visible = (viewport.height() / state.content).clamp(0.0, 1.0); | |
| 321 | + let height = (viewport.height() * visible).max(24.0); | |
| 322 | + let travel = viewport.height() - height; | |
| 323 | + let top = viewport.min.y + travel * (state.offset / max_offset).clamp(0.0, 1.0); | |
| 324 | + let bar = Rect::from_min_size( | |
| 325 | + egui::pos2(viewport.max.x - SCROLL_BAR_WIDTH, top), | |
| 326 | + egui::vec2(SCROLL_BAR_WIDTH, height), | |
| 327 | + ); | |
| 328 | + ui.painter().rect_filled( | |
| 329 | + bar, | |
| 330 | + SCROLL_BAR_WIDTH * 0.5, | |
| 331 | + Color32::from_rgba_unmultiplied(94, 92, 100, 136), | |
| 332 | + ); | |
| 333 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,333 @@ | |||
| 1 | +//! The immediate-mode cursor: a stack of live [`egui::Ui`] nodes. | ||
| 2 | +//! | ||
| 3 | +//! The ABI is push/pop (`vidya_card_begin` … `vidya_card_end`) while the Rust | ||
| 4 | +//! API is closure-based (`vidya::card(ui, theme, |ui| …)`). Bridging them means | ||
| 5 | +//! holding a child `Ui` open across FFI calls, which is exactly what | ||
| 6 | +//! [`egui::Frame::begin`] / [`egui::containers::frame::Prepared::end`] and | ||
| 7 | +//! [`egui::Ui::new_child`] are for. Every node is closed into its parent, so | ||
| 8 | +//! layout ends up identical to the closure form. | ||
| 9 | + | ||
| 10 | +use egui::containers::frame::Prepared; | ||
| 11 | +use egui::{Align, Align2, Color32, FontId, Id, Layout, Rect, Sense, Ui, UiBuilder, Vec2}; | ||
| 12 | +use vidya_core::Theme; | ||
| 13 | + | ||
| 14 | +/// A page taller than its viewport scrolls. `egui::ScrollArea` keeps its | ||
| 15 | +/// `begin`/`end` pair private — unlike `egui::Frame`, which is what the card | ||
| 16 | +/// node below borrows — so the page reimplements the half we need: offset the | ||
| 17 | +/// content, clip it to the viewport, and carry the offset between frames. | ||
| 18 | +#[derive(Clone, Copy, Default)] | ||
| 19 | +struct ScrollState { | ||
| 20 | + /// How far the content is scrolled up, in points. Always >= 0. | ||
| 21 | + offset: f32, | ||
| 22 | + /// Flick velocity in points per second, decaying under friction. | ||
| 23 | + vel: f32, | ||
| 24 | + /// Content height measured when the page closed last frame. | ||
| 25 | + content: f32, | ||
| 26 | +} | ||
| 27 | + | ||
| 28 | +impl ScrollState { | ||
| 29 | + fn load(ctx: &egui::Context, id: Id) -> Self { | ||
| 30 | + ctx.data(|d| d.get_temp(id)).unwrap_or_default() | ||
| 31 | + } | ||
| 32 | + | ||
| 33 | + fn store(self, ctx: &egui::Context, id: Id) { | ||
| 34 | + ctx.data_mut(|d| d.insert_temp(id, self)); | ||
| 35 | + } | ||
| 36 | +} | ||
| 37 | + | ||
| 38 | +/// Pixels per second squared, and the speed below which a flick stops. | ||
| 39 | +const SCROLL_FRICTION: f32 = 1000.0; | ||
| 40 | +const SCROLL_STOP_SPEED: f32 = 20.0; | ||
| 41 | +const SCROLL_BAR_WIDTH: f32 = 6.0; | ||
| 42 | + | ||
| 43 | +enum Node { | ||
| 44 | + /// A plain child region (root, page). | ||
| 45 | + Region(Ui), | ||
| 46 | + /// A framed surface (card) that paints its background on close. | ||
| 47 | + Framed(Box<Prepared>), | ||
| 48 | + /// A scrolling page: the content Ui plus what closing it needs. | ||
| 49 | + Scrolled { | ||
| 50 | + ui: Ui, | ||
| 51 | + id: Id, | ||
| 52 | + viewport: Rect, | ||
| 53 | + state: ScrollState, | ||
| 54 | + }, | ||
| 55 | +} | ||
| 56 | + | ||
| 57 | +impl Node { | ||
| 58 | + fn ui_mut(&mut self) -> &mut Ui { | ||
| 59 | + match self { | ||
| 60 | + Self::Region(ui) | Self::Scrolled { ui, .. } => ui, | ||
| 61 | + Self::Framed(prepared) => &mut prepared.content_ui, | ||
| 62 | + } | ||
| 63 | + } | ||
| 64 | +} | ||
| 65 | + | ||
| 66 | +/// Open UI nodes for the current frame. Empty between frames. | ||
| 67 | +#[derive(Default)] | ||
| 68 | +pub struct Stack { | ||
| 69 | + nodes: Vec<Node>, | ||
| 70 | +} | ||
| 71 | + | ||
| 72 | +impl Stack { | ||
| 73 | + pub fn is_active(&self) -> bool { | ||
| 74 | + !self.nodes.is_empty() | ||
| 75 | + } | ||
| 76 | + | ||
| 77 | + pub fn top(&mut self) -> Option<&mut Ui> { | ||
| 78 | + self.nodes.last_mut().map(Node::ui_mut) | ||
| 79 | + } | ||
| 80 | + | ||
| 81 | + pub fn push_root(&mut self, ctx: &egui::Context) { | ||
| 82 | + // The screen rect is the whole display: on Android it runs under the | ||
| 83 | + // status bar, the nav band and — when it is up — the soft keyboard. | ||
| 84 | + // Insetting the root here is what keeps a focused compose field above | ||
| 85 | + // the keyboard, and it costs nothing on desktop, where the insets are | ||
| 86 | + // zero. | ||
| 87 | + let rect = ctx.screen_rect(); | ||
| 88 | + #[cfg(target_os = "android")] | ||
| 89 | + let rect = { | ||
| 90 | + // Not `sync_system_chrome_from_android`: NativeActivity does not | ||
| 91 | + // shrink `content_rect` for the soft keyboard, so the measured | ||
| 92 | + // insets it installs are the bare bars — and installing them | ||
| 93 | + // suppresses the focus-based IME reserve that `system_chrome` falls | ||
| 94 | + // back to, which is the one that actually tracks the keyboard here. | ||
| 95 | + let chrome = vidya_core::system_chrome(ctx); | ||
| 96 | + Rect::from_min_max( | ||
| 97 | + egui::pos2(rect.min.x, rect.min.y + chrome.top), | ||
| 98 | + egui::pos2(rect.max.x, (rect.max.y - chrome.bottom()).max(rect.min.y)), | ||
| 99 | + ) | ||
| 100 | + }; | ||
| 101 | + let ui = Ui::new( | ||
| 102 | + ctx.clone(), | ||
| 103 | + Id::new("vidya_ffi_root"), | ||
| 104 | + UiBuilder::new() | ||
| 105 | + .layer_id(egui::LayerId::background()) | ||
| 106 | + .max_rect(rect) | ||
| 107 | + .layout(Layout::top_down(Align::Min)), | ||
| 108 | + ); | ||
| 109 | + self.nodes.push(Node::Region(ui)); | ||
| 110 | + } | ||
| 111 | + | ||
| 112 | + /// Vertical page: page padding, optional centred max width, section gaps. | ||
| 113 | + pub fn push_page(&mut self, theme: &Theme, max_width: f32) { | ||
| 114 | + let Some(parent) = self.top() else { | ||
| 115 | + return; | ||
| 116 | + }; | ||
| 117 | + let pad = theme.spacing.page; | ||
| 118 | + let mut rect = parent.available_rect_before_wrap().shrink(pad); | ||
| 119 | + if max_width > 0.0 && rect.width() > max_width { | ||
| 120 | + let cx = rect.center().x; | ||
| 121 | + rect = Rect::from_min_max( | ||
| 122 | + egui::pos2(cx - max_width * 0.5, rect.min.y), | ||
| 123 | + egui::pos2(cx + max_width * 0.5, rect.max.y), | ||
| 124 | + ); | ||
| 125 | + } | ||
| 126 | + | ||
| 127 | + let ctx = parent.ctx().clone(); | ||
| 128 | + let id = Id::new("vidya_ffi_page_scroll"); | ||
| 129 | + let mut state = ScrollState::load(&ctx, id); | ||
| 130 | + // Last frame's height decides whether this frame scrolls at all: the | ||
| 131 | + // content has not been emitted yet, and an immediate-mode page cannot | ||
| 132 | + // know its own size in advance. | ||
| 133 | + let max_offset = (state.content - rect.height()).max(0.0); | ||
| 134 | + | ||
| 135 | + if max_offset > 0.0 { | ||
| 136 | + // Claim the drag BEFORE any content exists, exactly as egui's own | ||
| 137 | + // ScrollArea does — a rect interacted after its children would | ||
| 138 | + // steal their presses instead of scrolling past them. | ||
| 139 | + let drag = parent.interact(rect, id.with("drag"), Sense::drag()); | ||
| 140 | + let dt = parent.input(|i| i.stable_dt).min(0.1); | ||
| 141 | + if drag.dragged() { | ||
| 142 | + state.offset -= parent.input(|i| i.pointer.delta().y); | ||
| 143 | + state.vel = 0.0; | ||
| 144 | + } else { | ||
| 145 | + if drag.drag_stopped() { | ||
| 146 | + state.vel = parent.input(|i| i.pointer.velocity().y); | ||
| 147 | + } | ||
| 148 | + if state.vel != 0.0 { | ||
| 149 | + state.offset -= state.vel * dt; | ||
| 150 | + let friction = SCROLL_FRICTION * dt; | ||
| 151 | + if friction > state.vel.abs() || state.vel.abs() < SCROLL_STOP_SPEED { | ||
| 152 | + state.vel = 0.0; | ||
| 153 | + } else { | ||
| 154 | + state.vel -= state.vel.signum() * friction; | ||
| 155 | + } | ||
| 156 | + ctx.request_repaint(); | ||
| 157 | + } | ||
| 158 | + } | ||
| 159 | + if parent.rect_contains_pointer(rect) { | ||
| 160 | + state.offset -= parent.input(|i| i.smooth_scroll_delta.y); | ||
| 161 | + } | ||
| 162 | + } else { | ||
| 163 | + state.vel = 0.0; | ||
| 164 | + } | ||
| 165 | + state.offset = state.offset.clamp(0.0, max_offset); | ||
| 166 | + | ||
| 167 | + // The content Ui starts above the viewport by the scroll offset and is | ||
| 168 | + // free to run as tall as it likes; the clip rect hides the overflow. | ||
| 169 | + let content = Rect::from_min_size( | ||
| 170 | + rect.min - Vec2::new(0.0, state.offset), | ||
| 171 | + egui::vec2(rect.width(), f32::INFINITY), | ||
| 172 | + ); | ||
| 173 | + let mut ui = parent.new_child( | ||
| 174 | + UiBuilder::new() | ||
| 175 | + .max_rect(content) | ||
| 176 | + .layout(Layout::top_down(Align::Min)), | ||
| 177 | + ); | ||
| 178 | + ui.set_clip_rect(rect.intersect(parent.clip_rect())); | ||
| 179 | + ui.spacing_mut().item_spacing.y = theme.spacing.md; | ||
| 180 | + self.nodes.push(Node::Scrolled { | ||
| 181 | + ui, | ||
| 182 | + id, | ||
| 183 | + viewport: rect, | ||
| 184 | + state, | ||
| 185 | + }); | ||
| 186 | + } | ||
| 187 | + | ||
| 188 | + /// Themed card surface. Mirrors `vidya::card`'s width discipline: content | ||
| 189 | + /// fills the parent column and never overflows past it. | ||
| 190 | + pub fn push_card(&mut self, theme: &Theme) { | ||
| 191 | + let Some(parent) = self.top() else { | ||
| 192 | + return; | ||
| 193 | + }; | ||
| 194 | + let outer = parent.available_width().max(1.0); | ||
| 195 | + parent.set_max_width(outer); | ||
| 196 | + | ||
| 197 | + let mut prepared = theme.card_frame().begin(parent); | ||
| 198 | + let inner = prepared.content_ui.available_width().max(1.0); | ||
| 199 | + prepared.content_ui.set_min_width(inner); | ||
| 200 | + prepared.content_ui.set_max_width(inner); | ||
| 201 | + prepared.content_ui.spacing_mut().item_spacing.y = theme.spacing.sm; | ||
| 202 | + self.nodes.push(Node::Framed(Box::new(prepared))); | ||
| 203 | + } | ||
| 204 | + | ||
| 205 | + /// Close the innermost node into its parent. The root is kept — only | ||
| 206 | + /// [`Self::unwind`] retires it — so a stray `vidya_page_end` cannot leave | ||
| 207 | + /// the frame without a cursor. | ||
| 208 | + pub fn pop(&mut self) { | ||
| 209 | + if self.nodes.len() > 1 { | ||
| 210 | + self.close_one(); | ||
| 211 | + } | ||
| 212 | + } | ||
| 213 | + | ||
| 214 | + /// Close every open node, innermost first. | ||
| 215 | + pub fn unwind(&mut self) { | ||
| 216 | + while !self.nodes.is_empty() { | ||
| 217 | + self.close_one(); | ||
| 218 | + } | ||
| 219 | + } | ||
| 220 | + | ||
| 221 | + fn close_one(&mut self) { | ||
| 222 | + let Some(node) = self.nodes.pop() else { | ||
| 223 | + return; | ||
| 224 | + }; | ||
| 225 | + let Some(parent) = self.nodes.last_mut().map(Node::ui_mut) else { | ||
| 226 | + return; // Root: nothing to fold into. | ||
| 227 | + }; | ||
| 228 | + match node { | ||
| 229 | + Node::Region(child) => { | ||
| 230 | + parent.advance_cursor_after_rect(child.min_rect()); | ||
| 231 | + } | ||
| 232 | + Node::Scrolled { | ||
| 233 | + ui, | ||
| 234 | + id, | ||
| 235 | + viewport, | ||
| 236 | + mut state, | ||
| 237 | + } => { | ||
| 238 | + state.content = ui.min_rect().height(); | ||
| 239 | + let ctx = parent.ctx().clone(); | ||
| 240 | + state.store(&ctx, id); | ||
| 241 | + paint_scroll_bar(parent, viewport, state); | ||
| 242 | + parent.advance_cursor_after_rect(viewport); | ||
| 243 | + } | ||
| 244 | + Node::Framed(prepared) => { | ||
| 245 | + prepared.end(parent); | ||
| 246 | + } | ||
| 247 | + } | ||
| 248 | + } | ||
| 249 | +} | ||
| 250 | + | ||
| 251 | +// ── Leaves ────────────────────────────────────────────────────────────────── | ||
| 252 | + | ||
| 253 | +pub fn gap(ui: &mut Ui, pixels: f32) { | ||
| 254 | + ui.add_space(pixels); | ||
| 255 | +} | ||
| 256 | + | ||
| 257 | +pub fn separator(ui: &mut Ui) { | ||
| 258 | + ui.separator(); | ||
| 259 | +} | ||
| 260 | + | ||
| 261 | +pub fn status(ui: &mut Ui, theme: &Theme, label: &str, live: bool) { | ||
| 262 | + ui.horizontal(|ui| { | ||
| 263 | + vidya_core::status_dot(ui, theme, live); | ||
| 264 | + vidya_core::body(ui, theme, label); | ||
| 265 | + }); | ||
| 266 | +} | ||
| 267 | + | ||
| 268 | +/// Single-line field over a caller-owned buffer. | ||
| 269 | +/// | ||
| 270 | +/// The whole [`egui::Response`] is returned rather than just "did it change": | ||
| 271 | +/// the tree backend also needs `lost_focus` to tell Enter from a click | ||
| 272 | +/// elsewhere. Callers that only want the change flag ask it for `.changed()`. | ||
| 273 | +pub fn text_field( | ||
| 274 | + ui: &mut Ui, | ||
| 275 | + theme: &Theme, | ||
| 276 | + text: &mut String, | ||
| 277 | + placeholder: &str, | ||
| 278 | +) -> egui::Response { | ||
| 279 | + let response = vidya_core::text_field_singleline(ui, theme, text); | ||
| 280 | + if text.is_empty() && !placeholder.is_empty() { | ||
| 281 | + // `text_field_singleline` has no hint-text parameter; paint one in the | ||
| 282 | + // field's own padding so the ABI's `placeholder` still means something. | ||
| 283 | + let rect = response.rect; | ||
| 284 | + ui.painter().text( | ||
| 285 | + egui::pos2(rect.left() + theme.spacing.field_pad_x, rect.center().y), | ||
| 286 | + Align2::LEFT_CENTER, | ||
| 287 | + placeholder, | ||
| 288 | + FontId::proportional(theme.type_scale.body), | ||
| 289 | + theme.palette.text_secondary, | ||
| 290 | + ); | ||
| 291 | + } | ||
| 292 | + response | ||
| 293 | +} | ||
| 294 | + | ||
| 295 | +/// Checkbox over a caller-owned value. Returns the value after input. | ||
| 296 | +pub fn checkbox(ui: &mut Ui, theme: &Theme, checked: bool, label: &str) -> (bool, bool) { | ||
| 297 | + let mut value = checked; | ||
| 298 | + let response = vidya_core::checkbox(ui, theme, &mut value, label); | ||
| 299 | + (value, response.changed()) | ||
| 300 | +} | ||
| 301 | + | ||
| 302 | +/// Button kinds, matching `VidyaButtonKind`. | ||
| 303 | +pub fn button(ui: &mut Ui, theme: &Theme, label: &str, kind: i32) -> bool { | ||
| 304 | + match kind { | ||
| 305 | + 1 => vidya_core::primary_button(ui, theme, label), | ||
| 306 | + 2 => vidya_core::destructive_button(ui, theme, label), | ||
| 307 | + _ => vidya_core::button(ui, theme, label), | ||
| 308 | + } | ||
| 309 | + .clicked() | ||
| 310 | +} | ||
| 311 | + | ||
| 312 | +/// A thin overlay bar on the viewport's right edge, drawn only when the page | ||
| 313 | +/// actually overflows. egui paints its own bars from private code, so this is | ||
| 314 | +/// the one piece of `ScrollArea` that has to be redrawn by hand. | ||
| 315 | +fn paint_scroll_bar(ui: &Ui, viewport: Rect, state: ScrollState) { | ||
| 316 | + let max_offset = (state.content - viewport.height()).max(0.0); | ||
| 317 | + if max_offset <= 0.0 { | ||
| 318 | + return; | ||
| 319 | + } | ||
| 320 | + let visible = (viewport.height() / state.content).clamp(0.0, 1.0); | ||
| 321 | + let height = (viewport.height() * visible).max(24.0); | ||
| 322 | + let travel = viewport.height() - height; | ||
| 323 | + let top = viewport.min.y + travel * (state.offset / max_offset).clamp(0.0, 1.0); | ||
| 324 | + let bar = Rect::from_min_size( | ||
| 325 | + egui::pos2(viewport.max.x - SCROLL_BAR_WIDTH, top), | ||
| 326 | + egui::vec2(SCROLL_BAR_WIDTH, height), | ||
| 327 | + ); | ||
| 328 | + ui.painter().rect_filled( | ||
| 329 | + bar, | ||
| 330 | + SCROLL_BAR_WIDTH * 0.5, | ||
| 331 | + Color32::from_rgba_unmultiplied(94, 92, 100, 136), | ||
| 332 | + ); | ||
| 333 | +} | ||
added
crates/vidya-core/Cargo.toml +30 -0 | new file mode 100644 | ||
| @@ -0,0 +1,30 @@ | ||
| 1 | +[package] | |
| 2 | +name = "vidya" | |
| 3 | +version = "0.1.0" | |
| 4 | +edition = "2021" | |
| 5 | +description = "Vidya — GNOME/HIG-inspired theme layer for egui (no GTK)" | |
| 6 | +license = "MIT" | |
| 7 | +repository = "https://tangled.org/nandi.uk/vidya" | |
| 8 | +keywords = ["egui", "theme", "gui", "vidya"] | |
| 9 | +categories = ["gui"] | |
| 10 | +readme = "README.md" | |
| 11 | + | |
| 12 | +[features] | |
| 13 | +default = [] | |
| 14 | +# Muted H.264-in-MP4 decode for [`video_player`] (openh264 + mp4 demux). | |
| 15 | +video = ["dep:openh264", "dep:mp4"] | |
| 16 | + | |
| 17 | +[dependencies] | |
| 18 | +egui = { version = "0.31", default-features = false } | |
| 19 | +# Decode bundled Twemoji PNGs for color reaction icons. | |
| 20 | +png = "0.17" | |
| 21 | +# Read the full Twemoji pack (`assets/emoji/twemoji-72x72.zip`). | |
| 22 | +zip = { version = "2", default-features = false, features = ["deflate"] } | |
| 23 | +# Optional: H.264 decode for chat / embed video previews. | |
| 24 | +openh264 = { version = "0.9", optional = true, default-features = false, features = ["source"] } | |
| 25 | +mp4 = { version = "0.14", optional = true } | |
| 26 | + | |
| 27 | +# `sync_system_chrome_from_android` reads insets off the NativeActivity handle, | |
| 28 | +# so the theme layer needs winit's Android glue — but only there. | |
| 29 | +[target.'cfg(target_os = "android")'.dependencies] | |
| 30 | +winit = { version = "0.30", default-features = false, features = ["android-native-activity"] } | |
| new file mode 100644 | |||
| @@ -0,0 +1,30 @@ | |||
| 1 | +[package] | ||
| 2 | +name = "vidya" | ||
| 3 | +version = "0.1.0" | ||
| 4 | +edition = "2021" | ||
| 5 | +description = "Vidya — GNOME/HIG-inspired theme layer for egui (no GTK)" | ||
| 6 | +license = "MIT" | ||
| 7 | +repository = "https://tangled.org/nandi.uk/vidya" | ||
| 8 | +keywords = ["egui", "theme", "gui", "vidya"] | ||
| 9 | +categories = ["gui"] | ||
| 10 | +readme = "README.md" | ||
| 11 | + | ||
| 12 | +[features] | ||
| 13 | +default = [] | ||
| 14 | +# Muted H.264-in-MP4 decode for [`video_player`] (openh264 + mp4 demux). | ||
| 15 | +video = ["dep:openh264", "dep:mp4"] | ||
| 16 | + | ||
| 17 | +[dependencies] | ||
| 18 | +egui = { version = "0.31", default-features = false } | ||
| 19 | +# Decode bundled Twemoji PNGs for color reaction icons. | ||
| 20 | +png = "0.17" | ||
| 21 | +# Read the full Twemoji pack (`assets/emoji/twemoji-72x72.zip`). | ||
| 22 | +zip = { version = "2", default-features = false, features = ["deflate"] } | ||
| 23 | +# Optional: H.264 decode for chat / embed video previews. | ||
| 24 | +openh264 = { version = "0.9", optional = true, default-features = false, features = ["source"] } | ||
| 25 | +mp4 = { version = "0.14", optional = true } | ||
| 26 | + | ||
| 27 | +# `sync_system_chrome_from_android` reads insets off the NativeActivity handle, | ||
| 28 | +# so the theme layer needs winit's Android glue — but only there. | ||
| 29 | +[target.'cfg(target_os = "android")'.dependencies] | ||
| 30 | +winit = { version = "0.30", default-features = false, features = ["android-native-activity"] } | ||
added
crates/vidya-core/README.md +233 -0 | new file mode 100644 | ||
| @@ -0,0 +1,233 @@ | ||
| 1 | +# Vidya | |
| 2 | + | |
| 3 | +GNOME/HIG-inspired **theme layer for [egui](https://github.com/emilk/egui)** — no GTK. | |
| 4 | + | |
| 5 | +Calm charcoal shells, a clear blue accent, soft borders, and a short type/spacing scale. **Dark is the product default** — ship `apply_dark` / `Theme::dark()` and stop there. Light (`Theme::light` / `apply_light`) exists for the showcase and for apps that explicitly need it; do not copy the demo’s dark/light toggle into every app. | |
| 6 | + | |
| 7 | +**Repo:** [tangled.org/nandi.uk/vidya](https://tangled.org/nandi.uk/vidya) | |
| 8 | + | |
| 9 | +Screenshots below are **Waydroid** (portrait Android) captures of the demo APK. | |
| 10 | + | |
| 11 | +<p align="center"> | |
| 12 | + <img src="docs/screenshots/mobile/vidya-home.png" alt="Vidya — Overview" width="320" /> | |
| 13 | + <img src="docs/screenshots/mobile/vidya-forms.png" alt="Vidya — Forms (themed checkbox)" width="320" /> | |
| 14 | + <img src="docs/screenshots/mobile/vidya-actions.png" alt="Vidya — Actions" width="320" /> | |
| 15 | +</p> | |
| 16 | + | |
| 17 | +<p align="center"> | |
| 18 | + <img src="docs/screenshots/mobile/vidya-colors.png" alt="Vidya — Palette" width="320" /> | |
| 19 | + <img src="docs/screenshots/mobile/vidya-type.png" alt="Vidya — Typography" width="320" /> | |
| 20 | + <img src="docs/screenshots/mobile/vidya-surfaces.png" alt="Vidya — Surfaces" width="320" /> | |
| 21 | +</p> | |
| 22 | + | |
| 23 | +## Aesthetic | |
| 24 | + | |
| 25 | +| Layer | Role | | |
| 26 | +|-------|------| | |
| 27 | +| Window / view / card / popover | Stacked surfaces (`#242424` → `#383838` in dark) | | |
| 28 | +| Accent | Adwaita-like blue (`#3584e4`) for primary actions, selection, and checked controls | | |
| 29 | +| Feedback | Destructive red, success green, warning amber | | |
| 30 | +| Type | Title 20 · title₂ 16 · body 14 · caption 12 | | |
| 31 | +| Spacing | 4 · 6 · 12 · 18 · 24 · control height 34 | | |
| 32 | +| Radius | 6 · 9 · 12 | | |
| 33 | + | |
| 34 | +`apply()` installs palette + spacing on the egui context so text fields, sliders, and combos inherit the shell. Checkboxes use a dedicated themed control (`checkbox`) with accent fill and a drawn checkmark. It also registers a tiny **symbol font fallback** (DejaVu subset) so UI punctuation such as `→`, `●`/`○`, disclosure `▾`/`▴`, dashes, and curly quotes does not render as hollow boxes on Android. | |
| 35 | + | |
| 36 | +**Emoji → color icons:** the full **Twemoji** 72×72 set (~3.8k glyphs) is embedded. Any reaction (flags, ZWJ sequences, skin tones) resolves via `emoji_icon` / `paint_emoji_in` / `reaction_chip`. Prefer these over raw Unicode on Android — egui’s default fonts only ship monochrome Noto Emoji. | |
| 37 | + | |
| 38 | +On **Android** (edge-to-edge NativeActivity), call `reserve_system_chrome(ctx, &theme)` once per frame **before** other panels, or use `top_header(ctx, &theme, |ui| { … })` for the app chrome. Prefer `sync_system_chrome_from_android(ctx, &android_app)` each frame so insets track `content_rect` (including when the soft keyboard is open). The IME reserve passes pointer events through so swipe typing on the system keyboard is not blocked. | |
| 39 | + | |
| 40 | +## Demo | |
| 41 | + | |
| 42 | +An interactive showcase walks through overview, typography, actions, surfaces, palette swatches, and forms. The demo includes a dark/light toggle so you can compare palettes — that control is for the showcase, not a recommended app pattern. | |
| 43 | + | |
| 44 | +### raylib and Jolt port | |
| 45 | + | |
| 46 | +The first native C/raylib port lives in [`raylib/`](raylib/README.md), with | |
| 47 | +Jolt FFI bindings and a Jolt showcase in [`jolt/`](jolt/README.md). The same ABI | |
| 48 | +is also implemented on **this** crate in [`ffi/`](ffi/README.md), so Jolt can | |
| 49 | +render through the Rust/egui layer instead of the C reimplementation — same | |
| 50 | +symbols, same bindings, selected by library search path. The C ABI | |
| 51 | +keeps raylib types private, so Jolt and Zig callers only exchange primitive | |
| 52 | +values and UTF-8 strings. This is an independent backend; the established Rust | |
| 53 | +implementation remains available while controls and layout facilities are | |
| 54 | +ported incrementally. | |
| 55 | + | |
| 56 | +### glimmer | |
| 57 | + | |
| 58 | +[`glimmer/`](glimmer/README.md) is a backend for | |
| 59 | +[glimmer](https://github.com/jolt-lang/glimmer), Jolt's reactive toolkit — so | |
| 60 | +components written as hiccup, with reagent-style reactive cells, render as Vidya | |
| 61 | +widgets. egui has no widgets for a reconciler to patch, so the widget tree lives | |
| 62 | +in Rust behind a second C ABI | |
| 63 | +([`ffi/include/vidya_tree.h`](ffi/include/vidya_tree.h)) that the | |
| 64 | +Rust/egui build alone implements: the caller mutates integer node handles, | |
| 65 | +one call a frame paints the whole tree, and interactions come back as events. | |
| 66 | + | |
| 67 | +```bash | |
| 68 | +nix run # apps.default → vidya-demo | |
| 69 | +nix run .#demo | |
| 70 | +# or from a remote flake: | |
| 71 | +nix run git+https://tangled.org/nandi.uk/vidya | |
| 72 | +``` | |
| 73 | + | |
| 74 | +```bash | |
| 75 | +nix develop # rust (+ android target) · just · adb | |
| 76 | + | |
| 77 | +just waydroid # in-tree cargo apk → install → launch on Waydroid | |
| 78 | +just host # desktop egui window | |
| 79 | +just install # same as waydroid (rebuild APK) | |
| 80 | +just launch # start installed activity only | |
| 81 | +just shots # Waydroid screencaps → docs/screenshots/mobile/ | |
| 82 | +``` | |
| 83 | + | |
| 84 | +```bash | |
| 85 | +just ffi # buck2 → build/libvidya.so (host) | |
| 86 | +just ffi-android # buck2 → build/android/arm64-v8a/libvidya.so | |
| 87 | +``` | |
| 88 | + | |
| 89 | +Both are the **same** Rust/egui sources; only the target configuration moves. | |
| 90 | +Android is the sole backend on device now — the earlier cimgui/raylib | |
| 91 | +NativeActivity port is gone. `just ffi-android` needs `ANDROID_NDK_HOME` for the | |
| 92 | +linker (r27+); rustc and the `aarch64-linux-android` libstd are DotSlash-pinned | |
| 93 | +like everything else, so there is no rustup target to add. | |
| 94 | + | |
| 95 | +`just waydroid` builds **in `android-demo/`** with the nix develop toolchain (no temp/isolated copy). | |
| 96 | + | |
| 97 | +Sections: | |
| 98 | + | |
| 99 | +- **Overview** — hero card, design tokens + **grid DSL** metrics table (demo can flip to light) | |
| 100 | +- **Typography** — type scale samples (grid) and hierarchy | |
| 101 | +- **Actions** — primary / default / destructive buttons, dialog footer, status pills | |
| 102 | +- **Surfaces** — layer stack table, card & header frames | |
| 103 | +- **Palette** — live semantic swatches (flip the shell to compare) | |
| 104 | +- **Forms** — themed inputs, accent checkbox, slider, combo, progress | |
| 105 | + | |
| 106 | +### Refreshing screenshots | |
| 107 | + | |
| 108 | +With a running Waydroid session: | |
| 109 | + | |
| 110 | +```bash | |
| 111 | +just install # rebuild + install APK | |
| 112 | +just shots # adb screencap per section → docs/screenshots/mobile/ | |
| 113 | +``` | |
| 114 | + | |
| 115 | +## Use | |
| 116 | + | |
| 117 | +Apps should start dark-only: | |
| 118 | + | |
| 119 | +```toml | |
| 120 | +[dependencies] | |
| 121 | +vidya = { git = "https://tangled.org/nandi.uk/vidya" } | |
| 122 | +# or: vidya = { git = "ssh://git@tangled.org/nandi.uk/vidya" } | |
| 123 | +egui = "0.31" | |
| 124 | +``` | |
| 125 | + | |
| 126 | +```rust | |
| 127 | +use vidya::{apply_dark, checkbox, primary_button, Theme}; | |
| 128 | + | |
| 129 | +apply_dark(ctx); | |
| 130 | +let th = Theme::dark(); | |
| 131 | +if primary_button(ui, &th, "Open").clicked() { | |
| 132 | + // … | |
| 133 | +} | |
| 134 | +checkbox(ui, &th, &mut sync, "Sync preferences"); | |
| 135 | +``` | |
| 136 | + | |
| 137 | +`Theme::light()` / `apply_light` are available when an app truly needs a light shell. Prefer not to expose a theme toggle unless that is a deliberate product choice (the Vidya demo does it to exercise both palettes). | |
| 138 | + | |
| 139 | +### Window icon | |
| 140 | + | |
| 141 | +Embed a PNG and attach it to the viewport (set Wayland `app_id` to match your `.desktop`): | |
| 142 | + | |
| 143 | +```rust | |
| 144 | +use egui::ViewportBuilder; | |
| 145 | +use vidya::with_app_icon_id; | |
| 146 | + | |
| 147 | +let viewport = with_app_icon_id( | |
| 148 | + ViewportBuilder::default().with_title("I/O Usage"), | |
| 149 | + "usage", | |
| 150 | + include_bytes!("../assets/usage-256.png"), | |
| 151 | +); | |
| 152 | +``` | |
| 153 | + | |
| 154 | +## Nix flake | |
| 155 | + | |
| 156 | +| Output | Role | | |
| 157 | +|--------|------| | |
| 158 | +| `apps.default` / `apps.demo` | Desktop showcase via **`cargo run`** (rustup + egui libs) | | |
| 159 | +| `packages.default` / `packages.demo` | Same launcher + `.desktop` entry | | |
| 160 | +| `packages.vidya` | Theme library sources + rlib bundle | | |
| 161 | +| `devShells.default` | just · adb · egui runtime libs (rustup for cargo) | | |
| 162 | + | |
| 163 | +```bash | |
| 164 | +nix run # cargo run --manifest-path host/Cargo.toml | |
| 165 | +nix run .#demo | |
| 166 | +nix build .#vidya | |
| 167 | +nix develop | |
| 168 | +``` | |
| 169 | + | |
| 170 | +`nix run` keeps your cwd: from the checkout it rebuilds the live tree (same as `just host`). The packaged `.desktop` falls back to the flake source with `CARGO_TARGET_DIR` under `~/.cache/vidya/`. | |
| 171 | + | |
| 172 | +As a flake input: | |
| 173 | + | |
| 174 | +```nix | |
| 175 | +inputs.vidya.url = "git+https://tangled.org/nandi.uk/vidya"; | |
| 176 | +``` | |
| 177 | + | |
| 178 | +## API | |
| 179 | + | |
| 180 | +| Item | Role | | |
| 181 | +|------|------| | |
| 182 | +| `Theme::dark()` / `light()` | Palette + spacing + type scale (`dark` for apps; `light` optional) | | |
| 183 | +| `apply` / `apply_dark` / `apply_light` | Install on `egui::Context` (+ symbol font); prefer `apply_dark` | | |
| 184 | +| `install_symbol_font` | Fallback glyphs for arrows / disclosure triangles / bullets / quotes | | |
| 185 | +| `emoji_icon` / `paint_emoji_in` / `has_emoji_icon` | Full Twemoji color set by codepoint | | |
| 186 | +| `Icon` / `icon` / `paint_icon_in` | Named shortcuts + stroke Plus / Copy | | |
| 187 | +| `icon_button` | Square tool button with a stroke/emoji icon | | |
| 188 | +| `reaction_chip` | Themed count chip with color emoji | | |
| 189 | +| `primary_button` / `button` / `destructive_button` | Styled actions | | |
| 190 | +| `checkbox` | Accent-filled checkbox with drawn checkmark | | |
| 191 | +| `status_dot` | Live/offline circle (drawn — no Unicode tofu on Android) | | |
| 192 | +| `text_field_singleline` / `text_field_multiline` | Text inputs with field padding (fill parent width) | | |
| 193 | +| `consume_command` / `consume_escape` | Consume Cmd/Ctrl+key or Esc (platform-aware) | | |
| 194 | +| `command_shortcut_label` / `escape_label` | `"Ctrl+F"` / `"⌘F"` / `"Esc"` for tooltips | | |
| 195 | +| `title` / `title_2` / `body` / `dim_label` | Text roles | | |
| 196 | +| `Theme::header_frame` / `card_frame` / `page_frame` | Layout chrome | | |
| 197 | +| `dialog` | Centered resizable window with `card_frame` (chain size / `.show`) | | |
| 198 | +| `Theme::text_edit_margin` | Inner field padding (12×8 default) | | |
| 199 | +| **Layout composition** | Prefer these over raw `set_max_width` / `Layout` plumbing | | |
| 200 | +| `fit_width` / `fill_width` | Pin children to residual width (no edge overflow) | | |
| 201 | +| `vstack` | Non-justified vertical stack (no giant gaps in tall parents) | | |
| 202 | +| `card` | Fill-width themed card + `vstack` content | | |
| 203 | +| `compact_card` | Fixed-width card that **hugs content** (gauge / anomaly tiles) | | |
| 204 | +| `pack` | Wrap compact tiles without stretching leftover horizontal space | | |
| 205 | +| `hflow` | Wrapping horizontal row (toolbars / chips) | | |
| 206 | +| `lead_trail` | Leading field + trailing actions without clipping the actions | | |
| 207 | +| `two_col` / `side_by_side` | Responsive two-column / stack (+ pure breakpoint policy) | | |
| 208 | +| `page_body` / `central_page` | **Enforced** scrollable page: top-level content is a **grid** (`GridCtx`) | | |
| 209 | +| `page_body_cols` / `central_page_cols` | Same, with explicit top-level `ColSpec`s | | |
| 210 | +| `page_scroll` | Escape hatch: scroll + width pin only (no grid) | | |
| 211 | +| `GridCtx::section` | Full-width page section (one row / one cell) — preferred page building block | | |
| 212 | +| `inset_row` | Soft inset row capped to parent width | | |
| 213 | +| `grid` / `grid_cols` | **Grid DSL** — pinned to residual width; cols share viewport budget | | |
| 214 | +| `grid_cols_with` / `GridOpts` | Grid with striping / spacing options (page shell uses non-striped) | | |
| 215 | +| `distribute_col_max` | Pure policy: per-column max widths so sum + gaps ≤ available | | |
| 216 | +| `ColSpec` | `Flex` / `Fixed` / `MetricBps` / `MetricRate` (floors; capped by residual) | | |
| 217 | +| `metric_cell` | Right-edge mono metric; clipped to column budget | | |
| 218 | +| `RowDsl` | `heading` / `text` / `dim` / `warn` / `metric` / `metric_bps` / `metric_rate` | | |
| 219 | +| `metric_bps` / `metric_rate` | Fixed-width monospace rate strings (no staircase columns) | | |
| 220 | +| `metric_cell` / `table_metric` / `table_text` | Low-level cells (prefer the row DSL) | | |
| 221 | +| `data_table` | Index-callback table helper on top of the grid DSL | | |
| 222 | +| `reserve_system_chrome` / `system_chrome` | Android status + nav safe areas | | |
| 223 | +| `sync_system_chrome_from_android` | Measured insets from `AndroidApp::content_rect` (Android) | | |
| 224 | +| `top_header` | Header panel with system chrome already reserved | | |
| 225 | +| **App icon** | Embed a PNG as the native window icon | | |
| 226 | +| `icon_data_from_png` | Decode PNG bytes → `egui::IconData` | | |
| 227 | +| `with_app_icon` | `ViewportBuilder` + embedded PNG icon | | |
| 228 | +| `with_app_icon_id` | Same + Wayland `app_id` (match `.desktop` / `StartupWMClass`) | | |
| 229 | +| `try_with_app_icon` / `try_with_app_icon_id` | Fallible variants | | |
| 230 | + | |
| 231 | +## License | |
| 232 | + | |
| 233 | +MIT | |
| new file mode 100644 | |||
| @@ -0,0 +1,233 @@ | |||
| 1 | +# Vidya | ||
| 2 | + | ||
| 3 | +GNOME/HIG-inspired **theme layer for [egui](https://github.com/emilk/egui)** — no GTK. | ||
| 4 | + | ||
| 5 | +Calm charcoal shells, a clear blue accent, soft borders, and a short type/spacing scale. **Dark is the product default** — ship `apply_dark` / `Theme::dark()` and stop there. Light (`Theme::light` / `apply_light`) exists for the showcase and for apps that explicitly need it; do not copy the demo’s dark/light toggle into every app. | ||
| 6 | + | ||
| 7 | +**Repo:** [tangled.org/nandi.uk/vidya](https://tangled.org/nandi.uk/vidya) | ||
| 8 | + | ||
| 9 | +Screenshots below are **Waydroid** (portrait Android) captures of the demo APK. | ||
| 10 | + | ||
| 11 | +<p align="center"> | ||
| 12 | + <img src="docs/screenshots/mobile/vidya-home.png" alt="Vidya — Overview" width="320" /> | ||
| 13 | + <img src="docs/screenshots/mobile/vidya-forms.png" alt="Vidya — Forms (themed checkbox)" width="320" /> | ||
| 14 | + <img src="docs/screenshots/mobile/vidya-actions.png" alt="Vidya — Actions" width="320" /> | ||
| 15 | +</p> | ||
| 16 | + | ||
| 17 | +<p align="center"> | ||
| 18 | + <img src="docs/screenshots/mobile/vidya-colors.png" alt="Vidya — Palette" width="320" /> | ||
| 19 | + <img src="docs/screenshots/mobile/vidya-type.png" alt="Vidya — Typography" width="320" /> | ||
| 20 | + <img src="docs/screenshots/mobile/vidya-surfaces.png" alt="Vidya — Surfaces" width="320" /> | ||
| 21 | +</p> | ||
| 22 | + | ||
| 23 | +## Aesthetic | ||
| 24 | + | ||
| 25 | +| Layer | Role | | ||
| 26 | +|-------|------| | ||
| 27 | +| Window / view / card / popover | Stacked surfaces (`#242424` → `#383838` in dark) | | ||
| 28 | +| Accent | Adwaita-like blue (`#3584e4`) for primary actions, selection, and checked controls | | ||
| 29 | +| Feedback | Destructive red, success green, warning amber | | ||
| 30 | +| Type | Title 20 · title₂ 16 · body 14 · caption 12 | | ||
| 31 | +| Spacing | 4 · 6 · 12 · 18 · 24 · control height 34 | | ||
| 32 | +| Radius | 6 · 9 · 12 | | ||
| 33 | + | ||
| 34 | +`apply()` installs palette + spacing on the egui context so text fields, sliders, and combos inherit the shell. Checkboxes use a dedicated themed control (`checkbox`) with accent fill and a drawn checkmark. It also registers a tiny **symbol font fallback** (DejaVu subset) so UI punctuation such as `→`, `●`/`○`, disclosure `▾`/`▴`, dashes, and curly quotes does not render as hollow boxes on Android. | ||
| 35 | + | ||
| 36 | +**Emoji → color icons:** the full **Twemoji** 72×72 set (~3.8k glyphs) is embedded. Any reaction (flags, ZWJ sequences, skin tones) resolves via `emoji_icon` / `paint_emoji_in` / `reaction_chip`. Prefer these over raw Unicode on Android — egui’s default fonts only ship monochrome Noto Emoji. | ||
| 37 | + | ||
| 38 | +On **Android** (edge-to-edge NativeActivity), call `reserve_system_chrome(ctx, &theme)` once per frame **before** other panels, or use `top_header(ctx, &theme, |ui| { … })` for the app chrome. Prefer `sync_system_chrome_from_android(ctx, &android_app)` each frame so insets track `content_rect` (including when the soft keyboard is open). The IME reserve passes pointer events through so swipe typing on the system keyboard is not blocked. | ||
| 39 | + | ||
| 40 | +## Demo | ||
| 41 | + | ||
| 42 | +An interactive showcase walks through overview, typography, actions, surfaces, palette swatches, and forms. The demo includes a dark/light toggle so you can compare palettes — that control is for the showcase, not a recommended app pattern. | ||
| 43 | + | ||
| 44 | +### raylib and Jolt port | ||
| 45 | + | ||
| 46 | +The first native C/raylib port lives in [`raylib/`](raylib/README.md), with | ||
| 47 | +Jolt FFI bindings and a Jolt showcase in [`jolt/`](jolt/README.md). The same ABI | ||
| 48 | +is also implemented on **this** crate in [`ffi/`](ffi/README.md), so Jolt can | ||
| 49 | +render through the Rust/egui layer instead of the C reimplementation — same | ||
| 50 | +symbols, same bindings, selected by library search path. The C ABI | ||
| 51 | +keeps raylib types private, so Jolt and Zig callers only exchange primitive | ||
| 52 | +values and UTF-8 strings. This is an independent backend; the established Rust | ||
| 53 | +implementation remains available while controls and layout facilities are | ||
| 54 | +ported incrementally. | ||
| 55 | + | ||
| 56 | +### glimmer | ||
| 57 | + | ||
| 58 | +[`glimmer/`](glimmer/README.md) is a backend for | ||
| 59 | +[glimmer](https://github.com/jolt-lang/glimmer), Jolt's reactive toolkit — so | ||
| 60 | +components written as hiccup, with reagent-style reactive cells, render as Vidya | ||
| 61 | +widgets. egui has no widgets for a reconciler to patch, so the widget tree lives | ||
| 62 | +in Rust behind a second C ABI | ||
| 63 | +([`ffi/include/vidya_tree.h`](ffi/include/vidya_tree.h)) that the | ||
| 64 | +Rust/egui build alone implements: the caller mutates integer node handles, | ||
| 65 | +one call a frame paints the whole tree, and interactions come back as events. | ||
| 66 | + | ||
| 67 | +```bash | ||
| 68 | +nix run # apps.default → vidya-demo | ||
| 69 | +nix run .#demo | ||
| 70 | +# or from a remote flake: | ||
| 71 | +nix run git+https://tangled.org/nandi.uk/vidya | ||
| 72 | +``` | ||
| 73 | + | ||
| 74 | +```bash | ||
| 75 | +nix develop # rust (+ android target) · just · adb | ||
| 76 | + | ||
| 77 | +just waydroid # in-tree cargo apk → install → launch on Waydroid | ||
| 78 | +just host # desktop egui window | ||
| 79 | +just install # same as waydroid (rebuild APK) | ||
| 80 | +just launch # start installed activity only | ||
| 81 | +just shots # Waydroid screencaps → docs/screenshots/mobile/ | ||
| 82 | +``` | ||
| 83 | + | ||
| 84 | +```bash | ||
| 85 | +just ffi # buck2 → build/libvidya.so (host) | ||
| 86 | +just ffi-android # buck2 → build/android/arm64-v8a/libvidya.so | ||
| 87 | +``` | ||
| 88 | + | ||
| 89 | +Both are the **same** Rust/egui sources; only the target configuration moves. | ||
| 90 | +Android is the sole backend on device now — the earlier cimgui/raylib | ||
| 91 | +NativeActivity port is gone. `just ffi-android` needs `ANDROID_NDK_HOME` for the | ||
| 92 | +linker (r27+); rustc and the `aarch64-linux-android` libstd are DotSlash-pinned | ||
| 93 | +like everything else, so there is no rustup target to add. | ||
| 94 | + | ||
| 95 | +`just waydroid` builds **in `android-demo/`** with the nix develop toolchain (no temp/isolated copy). | ||
| 96 | + | ||
| 97 | +Sections: | ||
| 98 | + | ||
| 99 | +- **Overview** — hero card, design tokens + **grid DSL** metrics table (demo can flip to light) | ||
| 100 | +- **Typography** — type scale samples (grid) and hierarchy | ||
| 101 | +- **Actions** — primary / default / destructive buttons, dialog footer, status pills | ||
| 102 | +- **Surfaces** — layer stack table, card & header frames | ||
| 103 | +- **Palette** — live semantic swatches (flip the shell to compare) | ||
| 104 | +- **Forms** — themed inputs, accent checkbox, slider, combo, progress | ||
| 105 | + | ||
| 106 | +### Refreshing screenshots | ||
| 107 | + | ||
| 108 | +With a running Waydroid session: | ||
| 109 | + | ||
| 110 | +```bash | ||
| 111 | +just install # rebuild + install APK | ||
| 112 | +just shots # adb screencap per section → docs/screenshots/mobile/ | ||
| 113 | +``` | ||
| 114 | + | ||
| 115 | +## Use | ||
| 116 | + | ||
| 117 | +Apps should start dark-only: | ||
| 118 | + | ||
| 119 | +```toml | ||
| 120 | +[dependencies] | ||
| 121 | +vidya = { git = "https://tangled.org/nandi.uk/vidya" } | ||
| 122 | +# or: vidya = { git = "ssh://git@tangled.org/nandi.uk/vidya" } | ||
| 123 | +egui = "0.31" | ||
| 124 | +``` | ||
| 125 | + | ||
| 126 | +```rust | ||
| 127 | +use vidya::{apply_dark, checkbox, primary_button, Theme}; | ||
| 128 | + | ||
| 129 | +apply_dark(ctx); | ||
| 130 | +let th = Theme::dark(); | ||
| 131 | +if primary_button(ui, &th, "Open").clicked() { | ||
| 132 | + // … | ||
| 133 | +} | ||
| 134 | +checkbox(ui, &th, &mut sync, "Sync preferences"); | ||
| 135 | +``` | ||
| 136 | + | ||
| 137 | +`Theme::light()` / `apply_light` are available when an app truly needs a light shell. Prefer not to expose a theme toggle unless that is a deliberate product choice (the Vidya demo does it to exercise both palettes). | ||
| 138 | + | ||
| 139 | +### Window icon | ||
| 140 | + | ||
| 141 | +Embed a PNG and attach it to the viewport (set Wayland `app_id` to match your `.desktop`): | ||
| 142 | + | ||
| 143 | +```rust | ||
| 144 | +use egui::ViewportBuilder; | ||
| 145 | +use vidya::with_app_icon_id; | ||
| 146 | + | ||
| 147 | +let viewport = with_app_icon_id( | ||
| 148 | + ViewportBuilder::default().with_title("I/O Usage"), | ||
| 149 | + "usage", | ||
| 150 | + include_bytes!("../assets/usage-256.png"), | ||
| 151 | +); | ||
| 152 | +``` | ||
| 153 | + | ||
| 154 | +## Nix flake | ||
| 155 | + | ||
| 156 | +| Output | Role | | ||
| 157 | +|--------|------| | ||
| 158 | +| `apps.default` / `apps.demo` | Desktop showcase via **`cargo run`** (rustup + egui libs) | | ||
| 159 | +| `packages.default` / `packages.demo` | Same launcher + `.desktop` entry | | ||
| 160 | +| `packages.vidya` | Theme library sources + rlib bundle | | ||
| 161 | +| `devShells.default` | just · adb · egui runtime libs (rustup for cargo) | | ||
| 162 | + | ||
| 163 | +```bash | ||
| 164 | +nix run # cargo run --manifest-path host/Cargo.toml | ||
| 165 | +nix run .#demo | ||
| 166 | +nix build .#vidya | ||
| 167 | +nix develop | ||
| 168 | +``` | ||
| 169 | + | ||
| 170 | +`nix run` keeps your cwd: from the checkout it rebuilds the live tree (same as `just host`). The packaged `.desktop` falls back to the flake source with `CARGO_TARGET_DIR` under `~/.cache/vidya/`. | ||
| 171 | + | ||
| 172 | +As a flake input: | ||
| 173 | + | ||
| 174 | +```nix | ||
| 175 | +inputs.vidya.url = "git+https://tangled.org/nandi.uk/vidya"; | ||
| 176 | +``` | ||
| 177 | + | ||
| 178 | +## API | ||
| 179 | + | ||
| 180 | +| Item | Role | | ||
| 181 | +|------|------| | ||
| 182 | +| `Theme::dark()` / `light()` | Palette + spacing + type scale (`dark` for apps; `light` optional) | | ||
| 183 | +| `apply` / `apply_dark` / `apply_light` | Install on `egui::Context` (+ symbol font); prefer `apply_dark` | | ||
| 184 | +| `install_symbol_font` | Fallback glyphs for arrows / disclosure triangles / bullets / quotes | | ||
| 185 | +| `emoji_icon` / `paint_emoji_in` / `has_emoji_icon` | Full Twemoji color set by codepoint | | ||
| 186 | +| `Icon` / `icon` / `paint_icon_in` | Named shortcuts + stroke Plus / Copy | | ||
| 187 | +| `icon_button` | Square tool button with a stroke/emoji icon | | ||
| 188 | +| `reaction_chip` | Themed count chip with color emoji | | ||
| 189 | +| `primary_button` / `button` / `destructive_button` | Styled actions | | ||
| 190 | +| `checkbox` | Accent-filled checkbox with drawn checkmark | | ||
| 191 | +| `status_dot` | Live/offline circle (drawn — no Unicode tofu on Android) | | ||
| 192 | +| `text_field_singleline` / `text_field_multiline` | Text inputs with field padding (fill parent width) | | ||
| 193 | +| `consume_command` / `consume_escape` | Consume Cmd/Ctrl+key or Esc (platform-aware) | | ||
| 194 | +| `command_shortcut_label` / `escape_label` | `"Ctrl+F"` / `"⌘F"` / `"Esc"` for tooltips | | ||
| 195 | +| `title` / `title_2` / `body` / `dim_label` | Text roles | | ||
| 196 | +| `Theme::header_frame` / `card_frame` / `page_frame` | Layout chrome | | ||
| 197 | +| `dialog` | Centered resizable window with `card_frame` (chain size / `.show`) | | ||
| 198 | +| `Theme::text_edit_margin` | Inner field padding (12×8 default) | | ||
| 199 | +| **Layout composition** | Prefer these over raw `set_max_width` / `Layout` plumbing | | ||
| 200 | +| `fit_width` / `fill_width` | Pin children to residual width (no edge overflow) | | ||
| 201 | +| `vstack` | Non-justified vertical stack (no giant gaps in tall parents) | | ||
| 202 | +| `card` | Fill-width themed card + `vstack` content | | ||
| 203 | +| `compact_card` | Fixed-width card that **hugs content** (gauge / anomaly tiles) | | ||
| 204 | +| `pack` | Wrap compact tiles without stretching leftover horizontal space | | ||
| 205 | +| `hflow` | Wrapping horizontal row (toolbars / chips) | | ||
| 206 | +| `lead_trail` | Leading field + trailing actions without clipping the actions | | ||
| 207 | +| `two_col` / `side_by_side` | Responsive two-column / stack (+ pure breakpoint policy) | | ||
| 208 | +| `page_body` / `central_page` | **Enforced** scrollable page: top-level content is a **grid** (`GridCtx`) | | ||
| 209 | +| `page_body_cols` / `central_page_cols` | Same, with explicit top-level `ColSpec`s | | ||
| 210 | +| `page_scroll` | Escape hatch: scroll + width pin only (no grid) | | ||
| 211 | +| `GridCtx::section` | Full-width page section (one row / one cell) — preferred page building block | | ||
| 212 | +| `inset_row` | Soft inset row capped to parent width | | ||
| 213 | +| `grid` / `grid_cols` | **Grid DSL** — pinned to residual width; cols share viewport budget | | ||
| 214 | +| `grid_cols_with` / `GridOpts` | Grid with striping / spacing options (page shell uses non-striped) | | ||
| 215 | +| `distribute_col_max` | Pure policy: per-column max widths so sum + gaps ≤ available | | ||
| 216 | +| `ColSpec` | `Flex` / `Fixed` / `MetricBps` / `MetricRate` (floors; capped by residual) | | ||
| 217 | +| `metric_cell` | Right-edge mono metric; clipped to column budget | | ||
| 218 | +| `RowDsl` | `heading` / `text` / `dim` / `warn` / `metric` / `metric_bps` / `metric_rate` | | ||
| 219 | +| `metric_bps` / `metric_rate` | Fixed-width monospace rate strings (no staircase columns) | | ||
| 220 | +| `metric_cell` / `table_metric` / `table_text` | Low-level cells (prefer the row DSL) | | ||
| 221 | +| `data_table` | Index-callback table helper on top of the grid DSL | | ||
| 222 | +| `reserve_system_chrome` / `system_chrome` | Android status + nav safe areas | | ||
| 223 | +| `sync_system_chrome_from_android` | Measured insets from `AndroidApp::content_rect` (Android) | | ||
| 224 | +| `top_header` | Header panel with system chrome already reserved | | ||
| 225 | +| **App icon** | Embed a PNG as the native window icon | | ||
| 226 | +| `icon_data_from_png` | Decode PNG bytes → `egui::IconData` | | ||
| 227 | +| `with_app_icon` | `ViewportBuilder` + embedded PNG icon | | ||
| 228 | +| `with_app_icon_id` | Same + Wayland `app_id` (match `.desktop` / `StartupWMClass`) | | ||
| 229 | +| `try_with_app_icon` / `try_with_app_icon_id` | Fallible variants | | ||
| 230 | + | ||
| 231 | +## License | ||
| 232 | + | ||
| 233 | +MIT | ||
added
crates/vidya-core/assets/NOTICE +21 -0 | new file mode 100644 | ||
| @@ -0,0 +1,21 @@ | ||
| 1 | +vidya-symbols.ttf is a glyph subset of DejaVu Sans. | |
| 2 | + | |
| 3 | +DejaVu fonts are based on the Bitstream Vera fonts, with additional | |
| 4 | +characters. License (summary): free to use, modify, and redistribute; | |
| 5 | +must not use Bitstream/DejaVu names to endorse derived works without | |
| 6 | +permission. Full text: https://dejavu-fonts.github.io/License.html | |
| 7 | + | |
| 8 | +Generated with fonttools pyftsubset for UI punctuation/symbols, | |
| 9 | +common math-in-prose Unicode (Greek, letterlike ℕℤℚℝℂ, superscripts/ | |
| 10 | +subscripts, mathematical operators), and box drawing / block elements | |
| 11 | +(U+2500–U+259F) for profile ASCII art. Rebuild: | |
| 12 | +`scripts/rebuild-symbols-ttf.sh`. | |
| 13 | + | |
| 14 | +--- | |
| 15 | + | |
| 16 | +Color emoji pack `assets/emoji/twemoji-72x72.zip` is from Twemoji | |
| 17 | +(https://github.com/jdecked/twemoji), Copyright Twitter, Inc. and other | |
| 18 | +contributors. Licensed under Creative Commons Attribution 4.0 International | |
| 19 | +(CC-BY 4.0): https://creativecommons.org/licenses/by/4.0/ | |
| 20 | + | |
| 21 | +Graphics adapted for embedding; attribution required. | |
| new file mode 100644 | |||
| @@ -0,0 +1,21 @@ | |||
| 1 | +vidya-symbols.ttf is a glyph subset of DejaVu Sans. | ||
| 2 | + | ||
| 3 | +DejaVu fonts are based on the Bitstream Vera fonts, with additional | ||
| 4 | +characters. License (summary): free to use, modify, and redistribute; | ||
| 5 | +must not use Bitstream/DejaVu names to endorse derived works without | ||
| 6 | +permission. Full text: https://dejavu-fonts.github.io/License.html | ||
| 7 | + | ||
| 8 | +Generated with fonttools pyftsubset for UI punctuation/symbols, | ||
| 9 | +common math-in-prose Unicode (Greek, letterlike ℕℤℚℝℂ, superscripts/ | ||
| 10 | +subscripts, mathematical operators), and box drawing / block elements | ||
| 11 | +(U+2500–U+259F) for profile ASCII art. Rebuild: | ||
| 12 | +`scripts/rebuild-symbols-ttf.sh`. | ||
| 13 | + | ||
| 14 | +--- | ||
| 15 | + | ||
| 16 | +Color emoji pack `assets/emoji/twemoji-72x72.zip` is from Twemoji | ||
| 17 | +(https://github.com/jdecked/twemoji), Copyright Twitter, Inc. and other | ||
| 18 | +contributors. Licensed under Creative Commons Attribution 4.0 International | ||
| 19 | +(CC-BY 4.0): https://creativecommons.org/licenses/by/4.0/ | ||
| 20 | + | ||
| 21 | +Graphics adapted for embedding; attribution required. | ||
added
crates/vidya-core/assets/emoji/twemoji-72x72.zip +0 -0 | new file mode 100644 | ||
| Binary files /dev/null and b/crates/vidya-core/assets/emoji/twemoji-72x72.zip differ | ||
| new file mode 100644 | |||
| Binary files /dev/null and b/crates/vidya-core/assets/emoji/twemoji-72x72.zip differ | Binary files /dev/null and b/crates/vidya-core/assets/emoji/twemoji-72x72.zip differ | ||
added
crates/vidya-core/assets/vidya-symbols.ttf +0 -0 | new file mode 100644 | ||
| Binary files /dev/null and b/crates/vidya-core/assets/vidya-symbols.ttf differ | ||
| new file mode 100644 | |||
| Binary files /dev/null and b/crates/vidya-core/assets/vidya-symbols.ttf differ | Binary files /dev/null and b/crates/vidya-core/assets/vidya-symbols.ttf differ | ||
added
crates/vidya-core/src/app_icon.rs +171 -0 | new file mode 100644 | ||
| @@ -0,0 +1,171 @@ | ||
| 1 | +//! Embed a window / taskbar app icon from PNG bytes. | |
| 2 | +//! | |
| 3 | +//! Apps typically `include_bytes!` a 128–256px PNG and pass it through | |
| 4 | +//! [`with_app_icon`] / [`with_app_icon_id`] when building the eframe viewport: | |
| 5 | +//! | |
| 6 | +//! ```ignore | |
| 7 | +//! use egui::ViewportBuilder; | |
| 8 | +//! use vidya::with_app_icon_id; | |
| 9 | +//! | |
| 10 | +//! let viewport = with_app_icon_id( | |
| 11 | +//! ViewportBuilder::default().with_title("My App"), | |
| 12 | +//! "my-app", // Wayland app_id — match .desktop StartupWMClass / file id | |
| 13 | +//! include_bytes!("../assets/icon-256.png"), | |
| 14 | +//! ); | |
| 15 | +//! ``` | |
| 16 | +//! | |
| 17 | +//! On Wayland, [`with_app_icon_id`] is preferred: the compositor matches | |
| 18 | +//! `app_id` to a FreeDesktop entry’s `Icon=` when the `.desktop` is on | |
| 19 | +//! `XDG_DATA_DIRS`. The embedded icon still sets the client-side window icon | |
| 20 | +//! where the platform supports it (X11, Windows, …). | |
| 21 | + | |
| 22 | +use std::io::Cursor; | |
| 23 | +use std::sync::Arc; | |
| 24 | + | |
| 25 | +use egui::{IconData, ViewportBuilder}; | |
| 26 | + | |
| 27 | +/// Error decoding an app icon PNG. | |
| 28 | +#[derive(Debug, Clone)] | |
| 29 | +pub struct AppIconError(String); | |
| 30 | + | |
| 31 | +impl AppIconError { | |
| 32 | + pub fn message(&self) -> &str { | |
| 33 | + &self.0 | |
| 34 | + } | |
| 35 | +} | |
| 36 | + | |
| 37 | +impl std::fmt::Display for AppIconError { | |
| 38 | + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { | |
| 39 | + write!(f, "app icon: {}", self.0) | |
| 40 | + } | |
| 41 | +} | |
| 42 | + | |
| 43 | +impl std::error::Error for AppIconError {} | |
| 44 | + | |
| 45 | +/// Decode PNG bytes into egui [`IconData`] (RGBA8, unpremultiplied). | |
| 46 | +/// | |
| 47 | +/// Intended for `include_bytes!("…png")` assets used as the native window icon. | |
| 48 | +pub fn icon_data_from_png(png_bytes: &[u8]) -> Result<IconData, AppIconError> { | |
| 49 | + let (width, height, rgba) = decode_png_rgba(png_bytes)?; | |
| 50 | + Ok(IconData { | |
| 51 | + rgba, | |
| 52 | + width, | |
| 53 | + height, | |
| 54 | + }) | |
| 55 | +} | |
| 56 | + | |
| 57 | +/// Attach an embedded PNG as the viewport window icon. | |
| 58 | +/// | |
| 59 | +/// On decode failure the viewport is returned unchanged so a bad asset never | |
| 60 | +/// blocks app launch. | |
| 61 | +pub fn with_app_icon(viewport: ViewportBuilder, png_bytes: &[u8]) -> ViewportBuilder { | |
| 62 | + match icon_data_from_png(png_bytes) { | |
| 63 | + Ok(icon) => viewport.with_icon(Arc::new(icon)), | |
| 64 | + Err(_) => viewport, | |
| 65 | + } | |
| 66 | +} | |
| 67 | + | |
| 68 | +/// Like [`with_app_icon`], and set Wayland `app_id`. | |
| 69 | +/// | |
| 70 | +/// `app_id` should match the FreeDesktop application id / `StartupWMClass` | |
| 71 | +/// (e.g. `"usage"` for `usage.desktop`). | |
| 72 | +pub fn with_app_icon_id( | |
| 73 | + viewport: ViewportBuilder, | |
| 74 | + app_id: impl Into<String>, | |
| 75 | + png_bytes: &[u8], | |
| 76 | +) -> ViewportBuilder { | |
| 77 | + with_app_icon(viewport.with_app_id(app_id), png_bytes) | |
| 78 | +} | |
| 79 | + | |
| 80 | +/// Fallible variant of [`with_app_icon`]. | |
| 81 | +pub fn try_with_app_icon( | |
| 82 | + viewport: ViewportBuilder, | |
| 83 | + png_bytes: &[u8], | |
| 84 | +) -> Result<ViewportBuilder, AppIconError> { | |
| 85 | + let icon = icon_data_from_png(png_bytes)?; | |
| 86 | + Ok(viewport.with_icon(Arc::new(icon))) | |
| 87 | +} | |
| 88 | + | |
| 89 | +/// Fallible variant of [`with_app_icon_id`]. | |
| 90 | +pub fn try_with_app_icon_id( | |
| 91 | + viewport: ViewportBuilder, | |
| 92 | + app_id: impl Into<String>, | |
| 93 | + png_bytes: &[u8], | |
| 94 | +) -> Result<ViewportBuilder, AppIconError> { | |
| 95 | + try_with_app_icon(viewport.with_app_id(app_id), png_bytes) | |
| 96 | +} | |
| 97 | + | |
| 98 | +fn decode_png_rgba(bytes: &[u8]) -> Result<(u32, u32, Vec<u8>), AppIconError> { | |
| 99 | + let mut decoder = png::Decoder::new(Cursor::new(bytes)); | |
| 100 | + decoder.set_transformations(png::Transformations::EXPAND | png::Transformations::ALPHA); | |
| 101 | + let mut reader = decoder | |
| 102 | + .read_info() | |
| 103 | + .map_err(|e| AppIconError(format!("png header: {e}")))?; | |
| 104 | + let mut buf = vec![0; reader.output_buffer_size()]; | |
| 105 | + let info = reader | |
| 106 | + .next_frame(&mut buf) | |
| 107 | + .map_err(|e| AppIconError(format!("png frame: {e}")))?; | |
| 108 | + let w = info.width; | |
| 109 | + let h = info.height; | |
| 110 | + let raw = &buf[..info.buffer_size()]; | |
| 111 | + let rgba: Vec<u8> = match info.color_type { | |
| 112 | + png::ColorType::Rgba => raw.to_vec(), | |
| 113 | + png::ColorType::Rgb => { | |
| 114 | + let mut out = Vec::with_capacity((w * h * 4) as usize); | |
| 115 | + for chunk in raw.chunks_exact(3) { | |
| 116 | + out.extend_from_slice(&[chunk[0], chunk[1], chunk[2], 255]); | |
| 117 | + } | |
| 118 | + out | |
| 119 | + } | |
| 120 | + other => { | |
| 121 | + return Err(AppIconError(format!( | |
| 122 | + "unsupported png color type: {other:?}" | |
| 123 | + ))); | |
| 124 | + } | |
| 125 | + }; | |
| 126 | + if rgba.len() != (w * h * 4) as usize { | |
| 127 | + return Err(AppIconError(format!( | |
| 128 | + "rgba length {} != {}×{}×4", | |
| 129 | + rgba.len(), | |
| 130 | + w, | |
| 131 | + h | |
| 132 | + ))); | |
| 133 | + } | |
| 134 | + Ok((w, h, rgba)) | |
| 135 | +} | |
| 136 | + | |
| 137 | +#[cfg(test)] | |
| 138 | +mod tests { | |
| 139 | + use super::*; | |
| 140 | + | |
| 141 | + /// 1×1 red RGB PNG (valid CRC). | |
| 142 | + const TINY_PNG: &[u8] = &[ | |
| 143 | + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, | |
| 144 | + 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x02, 0x00, 0x00, 0x00, 0x90, | |
| 145 | + 0x77, 0x53, 0xde, 0x00, 0x00, 0x00, 0x0c, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9c, 0x63, 0xf8, | |
| 146 | + 0xcf, 0xc0, 0x00, 0x00, 0x03, 0x01, 0x01, 0x00, 0xc9, 0xfe, 0x92, 0xef, 0x00, 0x00, 0x00, | |
| 147 | + 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82, | |
| 148 | + ]; | |
| 149 | + | |
| 150 | + #[test] | |
| 151 | + fn icon_data_from_tiny_png() { | |
| 152 | + let icon = icon_data_from_png(TINY_PNG).expect("decode"); | |
| 153 | + assert_eq!(icon.width, 1); | |
| 154 | + assert_eq!(icon.height, 1); | |
| 155 | + assert_eq!(icon.rgba.len(), 4); | |
| 156 | + } | |
| 157 | + | |
| 158 | + #[test] | |
| 159 | + fn with_app_icon_id_sets_app_id() { | |
| 160 | + let vp = with_app_icon_id(ViewportBuilder::default(), "usage", TINY_PNG); | |
| 161 | + assert_eq!(vp.app_id.as_deref(), Some("usage")); | |
| 162 | + assert!(vp.icon.is_some()); | |
| 163 | + } | |
| 164 | + | |
| 165 | + #[test] | |
| 166 | + fn bad_png_leaves_viewport_unchanged() { | |
| 167 | + let vp = with_app_icon(ViewportBuilder::default(), b"not a png"); | |
| 168 | + assert!(vp.icon.is_none()); | |
| 169 | + assert!(icon_data_from_png(b"not a png").is_err()); | |
| 170 | + } | |
| 171 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,171 @@ | |||
| 1 | +//! Embed a window / taskbar app icon from PNG bytes. | ||
| 2 | +//! | ||
| 3 | +//! Apps typically `include_bytes!` a 128–256px PNG and pass it through | ||
| 4 | +//! [`with_app_icon`] / [`with_app_icon_id`] when building the eframe viewport: | ||
| 5 | +//! | ||
| 6 | +//! ```ignore | ||
| 7 | +//! use egui::ViewportBuilder; | ||
| 8 | +//! use vidya::with_app_icon_id; | ||
| 9 | +//! | ||
| 10 | +//! let viewport = with_app_icon_id( | ||
| 11 | +//! ViewportBuilder::default().with_title("My App"), | ||
| 12 | +//! "my-app", // Wayland app_id — match .desktop StartupWMClass / file id | ||
| 13 | +//! include_bytes!("../assets/icon-256.png"), | ||
| 14 | +//! ); | ||
| 15 | +//! ``` | ||
| 16 | +//! | ||
| 17 | +//! On Wayland, [`with_app_icon_id`] is preferred: the compositor matches | ||
| 18 | +//! `app_id` to a FreeDesktop entry’s `Icon=` when the `.desktop` is on | ||
| 19 | +//! `XDG_DATA_DIRS`. The embedded icon still sets the client-side window icon | ||
| 20 | +//! where the platform supports it (X11, Windows, …). | ||
| 21 | + | ||
| 22 | +use std::io::Cursor; | ||
| 23 | +use std::sync::Arc; | ||
| 24 | + | ||
| 25 | +use egui::{IconData, ViewportBuilder}; | ||
| 26 | + | ||
| 27 | +/// Error decoding an app icon PNG. | ||
| 28 | +#[derive(Debug, Clone)] | ||
| 29 | +pub struct AppIconError(String); | ||
| 30 | + | ||
| 31 | +impl AppIconError { | ||
| 32 | + pub fn message(&self) -> &str { | ||
| 33 | + &self.0 | ||
| 34 | + } | ||
| 35 | +} | ||
| 36 | + | ||
| 37 | +impl std::fmt::Display for AppIconError { | ||
| 38 | + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { | ||
| 39 | + write!(f, "app icon: {}", self.0) | ||
| 40 | + } | ||
| 41 | +} | ||
| 42 | + | ||
| 43 | +impl std::error::Error for AppIconError {} | ||
| 44 | + | ||
| 45 | +/// Decode PNG bytes into egui [`IconData`] (RGBA8, unpremultiplied). | ||
| 46 | +/// | ||
| 47 | +/// Intended for `include_bytes!("…png")` assets used as the native window icon. | ||
| 48 | +pub fn icon_data_from_png(png_bytes: &[u8]) -> Result<IconData, AppIconError> { | ||
| 49 | + let (width, height, rgba) = decode_png_rgba(png_bytes)?; | ||
| 50 | + Ok(IconData { | ||
| 51 | + rgba, | ||
| 52 | + width, | ||
| 53 | + height, | ||
| 54 | + }) | ||
| 55 | +} | ||
| 56 | + | ||
| 57 | +/// Attach an embedded PNG as the viewport window icon. | ||
| 58 | +/// | ||
| 59 | +/// On decode failure the viewport is returned unchanged so a bad asset never | ||
| 60 | +/// blocks app launch. | ||
| 61 | +pub fn with_app_icon(viewport: ViewportBuilder, png_bytes: &[u8]) -> ViewportBuilder { | ||
| 62 | + match icon_data_from_png(png_bytes) { | ||
| 63 | + Ok(icon) => viewport.with_icon(Arc::new(icon)), | ||
| 64 | + Err(_) => viewport, | ||
| 65 | + } | ||
| 66 | +} | ||
| 67 | + | ||
| 68 | +/// Like [`with_app_icon`], and set Wayland `app_id`. | ||
| 69 | +/// | ||
| 70 | +/// `app_id` should match the FreeDesktop application id / `StartupWMClass` | ||
| 71 | +/// (e.g. `"usage"` for `usage.desktop`). | ||
| 72 | +pub fn with_app_icon_id( | ||
| 73 | + viewport: ViewportBuilder, | ||
| 74 | + app_id: impl Into<String>, | ||
| 75 | + png_bytes: &[u8], | ||
| 76 | +) -> ViewportBuilder { | ||
| 77 | + with_app_icon(viewport.with_app_id(app_id), png_bytes) | ||
| 78 | +} | ||
| 79 | + | ||
| 80 | +/// Fallible variant of [`with_app_icon`]. | ||
| 81 | +pub fn try_with_app_icon( | ||
| 82 | + viewport: ViewportBuilder, | ||
| 83 | + png_bytes: &[u8], | ||
| 84 | +) -> Result<ViewportBuilder, AppIconError> { | ||
| 85 | + let icon = icon_data_from_png(png_bytes)?; | ||
| 86 | + Ok(viewport.with_icon(Arc::new(icon))) | ||
| 87 | +} | ||
| 88 | + | ||
| 89 | +/// Fallible variant of [`with_app_icon_id`]. | ||
| 90 | +pub fn try_with_app_icon_id( | ||
| 91 | + viewport: ViewportBuilder, | ||
| 92 | + app_id: impl Into<String>, | ||
| 93 | + png_bytes: &[u8], | ||
| 94 | +) -> Result<ViewportBuilder, AppIconError> { | ||
| 95 | + try_with_app_icon(viewport.with_app_id(app_id), png_bytes) | ||
| 96 | +} | ||
| 97 | + | ||
| 98 | +fn decode_png_rgba(bytes: &[u8]) -> Result<(u32, u32, Vec<u8>), AppIconError> { | ||
| 99 | + let mut decoder = png::Decoder::new(Cursor::new(bytes)); | ||
| 100 | + decoder.set_transformations(png::Transformations::EXPAND | png::Transformations::ALPHA); | ||
| 101 | + let mut reader = decoder | ||
| 102 | + .read_info() | ||
| 103 | + .map_err(|e| AppIconError(format!("png header: {e}")))?; | ||
| 104 | + let mut buf = vec![0; reader.output_buffer_size()]; | ||
| 105 | + let info = reader | ||
| 106 | + .next_frame(&mut buf) | ||
| 107 | + .map_err(|e| AppIconError(format!("png frame: {e}")))?; | ||
| 108 | + let w = info.width; | ||
| 109 | + let h = info.height; | ||
| 110 | + let raw = &buf[..info.buffer_size()]; | ||
| 111 | + let rgba: Vec<u8> = match info.color_type { | ||
| 112 | + png::ColorType::Rgba => raw.to_vec(), | ||
| 113 | + png::ColorType::Rgb => { | ||
| 114 | + let mut out = Vec::with_capacity((w * h * 4) as usize); | ||
| 115 | + for chunk in raw.chunks_exact(3) { | ||
| 116 | + out.extend_from_slice(&[chunk[0], chunk[1], chunk[2], 255]); | ||
| 117 | + } | ||
| 118 | + out | ||
| 119 | + } | ||
| 120 | + other => { | ||
| 121 | + return Err(AppIconError(format!( | ||
| 122 | + "unsupported png color type: {other:?}" | ||
| 123 | + ))); | ||
| 124 | + } | ||
| 125 | + }; | ||
| 126 | + if rgba.len() != (w * h * 4) as usize { | ||
| 127 | + return Err(AppIconError(format!( | ||
| 128 | + "rgba length {} != {}×{}×4", | ||
| 129 | + rgba.len(), | ||
| 130 | + w, | ||
| 131 | + h | ||
| 132 | + ))); | ||
| 133 | + } | ||
| 134 | + Ok((w, h, rgba)) | ||
| 135 | +} | ||
| 136 | + | ||
| 137 | +#[cfg(test)] | ||
| 138 | +mod tests { | ||
| 139 | + use super::*; | ||
| 140 | + | ||
| 141 | + /// 1×1 red RGB PNG (valid CRC). | ||
| 142 | + const TINY_PNG: &[u8] = &[ | ||
| 143 | + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, | ||
| 144 | + 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x02, 0x00, 0x00, 0x00, 0x90, | ||
| 145 | + 0x77, 0x53, 0xde, 0x00, 0x00, 0x00, 0x0c, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9c, 0x63, 0xf8, | ||
| 146 | + 0xcf, 0xc0, 0x00, 0x00, 0x03, 0x01, 0x01, 0x00, 0xc9, 0xfe, 0x92, 0xef, 0x00, 0x00, 0x00, | ||
| 147 | + 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82, | ||
| 148 | + ]; | ||
| 149 | + | ||
| 150 | + #[test] | ||
| 151 | + fn icon_data_from_tiny_png() { | ||
| 152 | + let icon = icon_data_from_png(TINY_PNG).expect("decode"); | ||
| 153 | + assert_eq!(icon.width, 1); | ||
| 154 | + assert_eq!(icon.height, 1); | ||
| 155 | + assert_eq!(icon.rgba.len(), 4); | ||
| 156 | + } | ||
| 157 | + | ||
| 158 | + #[test] | ||
| 159 | + fn with_app_icon_id_sets_app_id() { | ||
| 160 | + let vp = with_app_icon_id(ViewportBuilder::default(), "usage", TINY_PNG); | ||
| 161 | + assert_eq!(vp.app_id.as_deref(), Some("usage")); | ||
| 162 | + assert!(vp.icon.is_some()); | ||
| 163 | + } | ||
| 164 | + | ||
| 165 | + #[test] | ||
| 166 | + fn bad_png_leaves_viewport_unchanged() { | ||
| 167 | + let vp = with_app_icon(ViewportBuilder::default(), b"not a png"); | ||
| 168 | + assert!(vp.icon.is_none()); | ||
| 169 | + assert!(icon_data_from_png(b"not a png").is_err()); | ||
| 170 | + } | ||
| 171 | +} | ||
added
crates/vidya-core/src/chrome.rs +297 -0 | new file mode 100644 | ||
| @@ -0,0 +1,297 @@ | ||
| 1 | +//! System chrome (status bar / nav bar) safe areas. | |
| 2 | +//! | |
| 3 | +//! Android NativeActivity draws **edge-to-edge**: the window fills under the | |
| 4 | +//! system status bar (clock, battery, …) and the gesture/nav bar. Without an | |
| 5 | +//! explicit reserve, app chrome and labels sit under those system widgets. | |
| 6 | +//! | |
| 7 | +//! Call [`reserve_system_chrome`] once at the start of every frame (before any | |
| 8 | +//! other `TopBottomPanel` / `SidePanel` / `CentralPanel`), or use | |
| 9 | +//! [`top_header`] which does that for you. | |
| 10 | +//! | |
| 11 | +//! Prefer injecting measured insets via [`set_system_chrome`] or | |
| 12 | +//! [`sync_system_chrome_from_android`] (from `AndroidApp::content_rect` or | |
| 13 | +//! WindowInsets) so reserves match the device — hardcoded fallbacks are | |
| 14 | +//! intentionally tight for modern gesture-nav phones. | |
| 15 | + | |
| 16 | +use egui::{Align2, Context, Frame, Id, Margin, Sense, Ui, WidgetText, Window}; | |
| 17 | + | |
| 18 | +use crate::Theme; | |
| 19 | + | |
| 20 | +/// Temp-data id for app-supplied measured insets (see [`set_system_chrome`]). | |
| 21 | +const SYSTEM_CHROME_ID: &str = "vidya.system_chrome"; | |
| 22 | +/// Last measured nav-band height when the keyboard was hidden. | |
| 23 | +const NAV_HINT_ID: &str = "vidya.nav_bottom_hint"; | |
| 24 | + | |
| 25 | +/// Insets for system status / navigation chrome on edge-to-edge surfaces. | |
| 26 | +#[derive(Debug, Clone, Copy, PartialEq)] | |
| 27 | +pub struct SystemChrome { | |
| 28 | + /// Space under the status bar (clock, indicators). | |
| 29 | + pub top: f32, | |
| 30 | + /// Space above the system gesture / 3-button nav bar. | |
| 31 | + pub nav_bottom: f32, | |
| 32 | + /// Extra reserve when the soft keyboard is visible (sits above [`nav_bottom`]). | |
| 33 | + pub ime_bottom: f32, | |
| 34 | +} | |
| 35 | + | |
| 36 | +impl SystemChrome { | |
| 37 | + pub const ZERO: Self = Self { | |
| 38 | + top: 0.0, | |
| 39 | + nav_bottom: 0.0, | |
| 40 | + ime_bottom: 0.0, | |
| 41 | + }; | |
| 42 | + | |
| 43 | + /// Combined bottom inset (nav + IME). | |
| 44 | + #[inline] | |
| 45 | + pub fn bottom(self) -> f32 { | |
| 46 | + self.nav_bottom + self.ime_bottom | |
| 47 | + } | |
| 48 | + | |
| 49 | + pub fn is_zero(self) -> bool { | |
| 50 | + self.top <= 0.0 && self.bottom() <= 0.0 | |
| 51 | + } | |
| 52 | + | |
| 53 | + /// Build from status + nav insets (no IME reserve). | |
| 54 | + pub fn from_insets(top: f32, nav_bottom: f32) -> Self { | |
| 55 | + Self { | |
| 56 | + top: top.max(0.0), | |
| 57 | + nav_bottom: nav_bottom.max(0.0), | |
| 58 | + ime_bottom: 0.0, | |
| 59 | + } | |
| 60 | + } | |
| 61 | +} | |
| 62 | + | |
| 63 | +/// Inject measured system insets for this frame (egui points). | |
| 64 | +/// | |
| 65 | +/// Call once per frame **before** [`reserve_system_chrome`]. When set, | |
| 66 | +/// [`system_chrome`] uses these values instead of the platform fallbacks and | |
| 67 | +/// does **not** add focus-based IME padding on top (measured insets already | |
| 68 | +/// reflect keyboard visibility from `content_rect` / WindowInsets). | |
| 69 | +pub fn set_system_chrome(ctx: &Context, chrome: SystemChrome) { | |
| 70 | + ctx.data_mut(|d| d.insert_temp(Id::new(SYSTEM_CHROME_ID), chrome)); | |
| 71 | +} | |
| 72 | + | |
| 73 | +/// Read measured insets from [`AndroidApp::content_rect`] and call | |
| 74 | +/// [`set_system_chrome`]. | |
| 75 | +/// | |
| 76 | +/// Call once per frame on Android **before** [`reserve_system_chrome`]. When the | |
| 77 | +/// soft keyboard is open, `content_rect` shrinks and the derived bottom inset | |
| 78 | +/// includes the IME height so layout tracks the real keyboard band — even when | |
| 79 | +/// the user dismisses the keyboard via the IME close button while a text field | |
| 80 | +/// still holds focus. | |
| 81 | +#[cfg(target_os = "android")] | |
| 82 | +pub fn sync_system_chrome_from_android( | |
| 83 | + ctx: &Context, | |
| 84 | + app: &winit::platform::android::activity::AndroidApp, | |
| 85 | +) { | |
| 86 | + const NAV_FALLBACK: f32 = 20.0; | |
| 87 | + /// Minimum extra inset beyond the nav band before treating the IME as visible. | |
| 88 | + const IME_VISIBLE_THRESHOLD: f32 = 48.0; | |
| 89 | + | |
| 90 | + let rect = app.content_rect(); | |
| 91 | + let ppp = ctx.pixels_per_point().max(0.01); | |
| 92 | + let screen = ctx.screen_rect(); | |
| 93 | + | |
| 94 | + let top = (rect.top as f32 / ppp).max(0.0); | |
| 95 | + let content_bottom_pt = rect.bottom as f32 / ppp; | |
| 96 | + let total_bottom = (screen.height() - content_bottom_pt).max(0.0); | |
| 97 | + | |
| 98 | + let nav_hint = ctx | |
| 99 | + .data(|d| d.get_temp::<f32>(Id::new(NAV_HINT_ID))) | |
| 100 | + .unwrap_or(NAV_FALLBACK); | |
| 101 | + | |
| 102 | + // Derive keyboard visibility from content_rect, not wants_keyboard_input(): | |
| 103 | + // dismissing the IME hides the keyboard but often leaves text focus. | |
| 104 | + let keyboard_visible = total_bottom > nav_hint + IME_VISIBLE_THRESHOLD; | |
| 105 | + | |
| 106 | + if keyboard_visible { | |
| 107 | + let nav = nav_hint.clamp(0.0, total_bottom); | |
| 108 | + set_system_chrome( | |
| 109 | + ctx, | |
| 110 | + SystemChrome { | |
| 111 | + top, | |
| 112 | + nav_bottom: nav, | |
| 113 | + ime_bottom: (total_bottom - nav).max(0.0), | |
| 114 | + }, | |
| 115 | + ); | |
| 116 | + } else { | |
| 117 | + ctx.data_mut(|d| d.insert_temp(Id::new(NAV_HINT_ID), total_bottom)); | |
| 118 | + set_system_chrome(ctx, SystemChrome::from_insets(top, total_bottom)); | |
| 119 | + } | |
| 120 | +} | |
| 121 | + | |
| 122 | +/// Platform defaults for edge-to-edge drawing. | |
| 123 | +/// | |
| 124 | +/// Fallback values are **tight** for modern gesture-nav phones (≈24–28 dp | |
| 125 | +/// status, ≈16–24 dp gesture handle). Prefer [`set_system_chrome`] or | |
| 126 | +/// [`sync_system_chrome_from_android`] with measured `content_rect` / | |
| 127 | +/// WindowInsets when available. | |
| 128 | +/// | |
| 129 | +/// When a text field holds focus (`Context::wants_keyboard_input`), the bottom | |
| 130 | +/// inset grows so bottom bars / compose fields sit **above** the soft keyboard | |
| 131 | +/// (NativeActivity rarely resizes the GL surface for IME). | |
| 132 | +pub fn system_chrome(ctx: &Context) -> SystemChrome { | |
| 133 | + #[cfg(target_os = "android")] | |
| 134 | + { | |
| 135 | + let measured = ctx.data(|d| d.get_temp::<SystemChrome>(Id::new(SYSTEM_CHROME_ID))); | |
| 136 | + const TOP_FALLBACK: f32 = 36.0; | |
| 137 | + const NAV_FALLBACK: f32 = 20.0; | |
| 138 | + let top = match measured { | |
| 139 | + Some(c) if c.top >= 8.0 => c.top, | |
| 140 | + Some(c) => c.top.max(TOP_FALLBACK), | |
| 141 | + None => TOP_FALLBACK, | |
| 142 | + }; | |
| 143 | + | |
| 144 | + if let Some(m) = measured { | |
| 145 | + let chrome = SystemChrome { | |
| 146 | + top, | |
| 147 | + nav_bottom: m.nav_bottom.max(0.0), | |
| 148 | + ime_bottom: m.ime_bottom.max(0.0), | |
| 149 | + }; | |
| 150 | + // content_rect / WindowInsets may still be animating with the IME. | |
| 151 | + if chrome.ime_bottom > NAV_FALLBACK { | |
| 152 | + ctx.request_repaint(); | |
| 153 | + } | |
| 154 | + return chrome; | |
| 155 | + } | |
| 156 | + | |
| 157 | + let mut nav_bottom = NAV_FALLBACK; | |
| 158 | + let mut ime_bottom = 0.0; | |
| 159 | + | |
| 160 | + if ctx.wants_keyboard_input() { | |
| 161 | + let h = ctx.screen_rect().height(); | |
| 162 | + let ime_fallback = (h * 0.40).clamp(240.0, h * 0.52); | |
| 163 | + nav_bottom = NAV_FALLBACK.min(ime_fallback); | |
| 164 | + ime_bottom = (ime_fallback - nav_bottom).max(0.0); | |
| 165 | + ctx.request_repaint(); | |
| 166 | + } | |
| 167 | + | |
| 168 | + SystemChrome { | |
| 169 | + top, | |
| 170 | + nav_bottom, | |
| 171 | + ime_bottom, | |
| 172 | + } | |
| 173 | + } | |
| 174 | + #[cfg(not(target_os = "android"))] | |
| 175 | + { | |
| 176 | + let _ = ctx; | |
| 177 | + SystemChrome::ZERO | |
| 178 | + } | |
| 179 | +} | |
| 180 | + | |
| 181 | +/// Reserve top/bottom strips so **no** subsequent panel or central content can | |
| 182 | +/// paint under the system status or navigation bars. | |
| 183 | +/// | |
| 184 | +/// Call this **once per frame**, before other panels. Safe to call when insets | |
| 185 | +/// are zero (no-op on desktop). | |
| 186 | +/// | |
| 187 | +/// When the soft keyboard is open, the IME reserve does **not** absorb pointer | |
| 188 | +/// events so swipe typing on the system keyboard is not blocked. Only the nav | |
| 189 | +/// strip keeps a hover sink so widgets behind the gesture bar cannot steal taps. | |
| 190 | +pub fn reserve_system_chrome(ctx: &Context, theme: &Theme) { | |
| 191 | + let chrome = system_chrome(ctx); | |
| 192 | + if chrome.is_zero() { | |
| 193 | + return; | |
| 194 | + } | |
| 195 | + | |
| 196 | + let top_band = Frame::new() | |
| 197 | + .fill(theme.palette.headerbar_bg) | |
| 198 | + .inner_margin(Margin::ZERO); | |
| 199 | + let nav_band = Frame::new() | |
| 200 | + .fill(theme.palette.window_bg) | |
| 201 | + .inner_margin(Margin::ZERO); | |
| 202 | + // Transparent — only reserves layout; must not paint over the IME. | |
| 203 | + let ime_band = Frame::NONE; | |
| 204 | + | |
| 205 | + if chrome.top > 0.0 { | |
| 206 | + egui::TopBottomPanel::top("vidya_system_chrome_top") | |
| 207 | + .exact_height(chrome.top) | |
| 208 | + .frame(top_band) | |
| 209 | + .show_separator_line(false) | |
| 210 | + .show(ctx, |_ui| {}); | |
| 211 | + } | |
| 212 | + | |
| 213 | + // Bottom panels stack upward: declare nav first (screen edge), then IME. | |
| 214 | + if chrome.nav_bottom > 0.0 { | |
| 215 | + egui::TopBottomPanel::bottom("vidya_system_chrome_nav") | |
| 216 | + .exact_height(chrome.nav_bottom) | |
| 217 | + .frame(nav_band) | |
| 218 | + .show_separator_line(false) | |
| 219 | + .show(ctx, |ui| { | |
| 220 | + ui.allocate_exact_size(ui.available_size(), Sense::hover()); | |
| 221 | + }); | |
| 222 | + } | |
| 223 | + | |
| 224 | + if chrome.ime_bottom > 0.0 { | |
| 225 | + egui::TopBottomPanel::bottom("vidya_system_chrome_ime") | |
| 226 | + .exact_height(chrome.ime_bottom) | |
| 227 | + .frame(ime_band) | |
| 228 | + .show_separator_line(false) | |
| 229 | + .show(ctx, |ui| { | |
| 230 | + // Pass touches through to the system keyboard (swipe / glide typing). | |
| 231 | + ui.allocate_exact_size(ui.available_size(), Sense::empty()); | |
| 232 | + }); | |
| 233 | + } | |
| 234 | +} | |
| 235 | + | |
| 236 | +/// Top app header with system status bar already reserved. | |
| 237 | +/// | |
| 238 | +/// Preferred entry for shell chrome: apps cannot place title/status text under | |
| 239 | +/// the clock / indicators. Also reserves the bottom system nav band. | |
| 240 | +/// | |
| 241 | +/// ```ignore | |
| 242 | +/// vidya::top_header(ctx, &theme, |ui| { | |
| 243 | +/// ui.horizontal(|ui| { | |
| 244 | +/// vidya::title(ui, &theme, "My App"); | |
| 245 | +/// }); | |
| 246 | +/// }); | |
| 247 | +/// ``` | |
| 248 | +pub fn top_header(ctx: &Context, theme: &Theme, add_contents: impl FnOnce(&mut Ui)) { | |
| 249 | + reserve_system_chrome(ctx, theme); | |
| 250 | + egui::TopBottomPanel::top("vidya_app_header") | |
| 251 | + .frame(theme.header_frame()) | |
| 252 | + .show_separator_line(false) | |
| 253 | + .show(ctx, add_contents); | |
| 254 | +} | |
| 255 | + | |
| 256 | +/// Centered modal-style window with themed card chrome. | |
| 257 | +/// | |
| 258 | +/// Defaults: non-collapsible, **resizable**, centered, [`Theme::card_frame`]. | |
| 259 | +/// Chain `.default_size` / `.min_width` / `.resizable(false)` as needed, then | |
| 260 | +/// `.show`. | |
| 261 | +/// | |
| 262 | +/// ```ignore | |
| 263 | +/// vidya::dialog("Rename", &theme) | |
| 264 | +/// .default_width(360.0) | |
| 265 | +/// .min_width(280.0) | |
| 266 | +/// .show(ctx, |ui| { /* … */ }); | |
| 267 | +/// ``` | |
| 268 | +pub fn dialog<'a>(title: impl Into<WidgetText> + 'a, theme: &Theme) -> Window<'a> { | |
| 269 | + Window::new(title) | |
| 270 | + .collapsible(false) | |
| 271 | + .resizable(true) | |
| 272 | + .anchor(Align2::CENTER_CENTER, [0.0, 0.0]) | |
| 273 | + .frame(theme.card_frame()) | |
| 274 | +} | |
| 275 | + | |
| 276 | +#[cfg(test)] | |
| 277 | +mod tests { | |
| 278 | + use super::*; | |
| 279 | + | |
| 280 | + #[test] | |
| 281 | + fn system_chrome_bottom_sums_nav_and_ime() { | |
| 282 | + let c = SystemChrome { | |
| 283 | + top: 36.0, | |
| 284 | + nav_bottom: 20.0, | |
| 285 | + ime_bottom: 400.0, | |
| 286 | + }; | |
| 287 | + assert!((c.bottom() - 420.0).abs() < f32::EPSILON); | |
| 288 | + assert!(!c.is_zero()); | |
| 289 | + } | |
| 290 | + | |
| 291 | + #[test] | |
| 292 | + fn from_insets_zeroes_ime() { | |
| 293 | + let c = SystemChrome::from_insets(36.0, 20.0); | |
| 294 | + assert_eq!(c.ime_bottom, 0.0); | |
| 295 | + assert_eq!(c.nav_bottom, 20.0); | |
| 296 | + } | |
| 297 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,297 @@ | |||
| 1 | +//! System chrome (status bar / nav bar) safe areas. | ||
| 2 | +//! | ||
| 3 | +//! Android NativeActivity draws **edge-to-edge**: the window fills under the | ||
| 4 | +//! system status bar (clock, battery, …) and the gesture/nav bar. Without an | ||
| 5 | +//! explicit reserve, app chrome and labels sit under those system widgets. | ||
| 6 | +//! | ||
| 7 | +//! Call [`reserve_system_chrome`] once at the start of every frame (before any | ||
| 8 | +//! other `TopBottomPanel` / `SidePanel` / `CentralPanel`), or use | ||
| 9 | +//! [`top_header`] which does that for you. | ||
| 10 | +//! | ||
| 11 | +//! Prefer injecting measured insets via [`set_system_chrome`] or | ||
| 12 | +//! [`sync_system_chrome_from_android`] (from `AndroidApp::content_rect` or | ||
| 13 | +//! WindowInsets) so reserves match the device — hardcoded fallbacks are | ||
| 14 | +//! intentionally tight for modern gesture-nav phones. | ||
| 15 | + | ||
| 16 | +use egui::{Align2, Context, Frame, Id, Margin, Sense, Ui, WidgetText, Window}; | ||
| 17 | + | ||
| 18 | +use crate::Theme; | ||
| 19 | + | ||
| 20 | +/// Temp-data id for app-supplied measured insets (see [`set_system_chrome`]). | ||
| 21 | +const SYSTEM_CHROME_ID: &str = "vidya.system_chrome"; | ||
| 22 | +/// Last measured nav-band height when the keyboard was hidden. | ||
| 23 | +const NAV_HINT_ID: &str = "vidya.nav_bottom_hint"; | ||
| 24 | + | ||
| 25 | +/// Insets for system status / navigation chrome on edge-to-edge surfaces. | ||
| 26 | +#[derive(Debug, Clone, Copy, PartialEq)] | ||
| 27 | +pub struct SystemChrome { | ||
| 28 | + /// Space under the status bar (clock, indicators). | ||
| 29 | + pub top: f32, | ||
| 30 | + /// Space above the system gesture / 3-button nav bar. | ||
| 31 | + pub nav_bottom: f32, | ||
| 32 | + /// Extra reserve when the soft keyboard is visible (sits above [`nav_bottom`]). | ||
| 33 | + pub ime_bottom: f32, | ||
| 34 | +} | ||
| 35 | + | ||
| 36 | +impl SystemChrome { | ||
| 37 | + pub const ZERO: Self = Self { | ||
| 38 | + top: 0.0, | ||
| 39 | + nav_bottom: 0.0, | ||
| 40 | + ime_bottom: 0.0, | ||
| 41 | + }; | ||
| 42 | + | ||
| 43 | + /// Combined bottom inset (nav + IME). | ||
| 44 | + #[inline] | ||
| 45 | + pub fn bottom(self) -> f32 { | ||
| 46 | + self.nav_bottom + self.ime_bottom | ||
| 47 | + } | ||
| 48 | + | ||
| 49 | + pub fn is_zero(self) -> bool { | ||
| 50 | + self.top <= 0.0 && self.bottom() <= 0.0 | ||
| 51 | + } | ||
| 52 | + | ||
| 53 | + /// Build from status + nav insets (no IME reserve). | ||
| 54 | + pub fn from_insets(top: f32, nav_bottom: f32) -> Self { | ||
| 55 | + Self { | ||
| 56 | + top: top.max(0.0), | ||
| 57 | + nav_bottom: nav_bottom.max(0.0), | ||
| 58 | + ime_bottom: 0.0, | ||
| 59 | + } | ||
| 60 | + } | ||
| 61 | +} | ||
| 62 | + | ||
| 63 | +/// Inject measured system insets for this frame (egui points). | ||
| 64 | +/// | ||
| 65 | +/// Call once per frame **before** [`reserve_system_chrome`]. When set, | ||
| 66 | +/// [`system_chrome`] uses these values instead of the platform fallbacks and | ||
| 67 | +/// does **not** add focus-based IME padding on top (measured insets already | ||
| 68 | +/// reflect keyboard visibility from `content_rect` / WindowInsets). | ||
| 69 | +pub fn set_system_chrome(ctx: &Context, chrome: SystemChrome) { | ||
| 70 | + ctx.data_mut(|d| d.insert_temp(Id::new(SYSTEM_CHROME_ID), chrome)); | ||
| 71 | +} | ||
| 72 | + | ||
| 73 | +/// Read measured insets from [`AndroidApp::content_rect`] and call | ||
| 74 | +/// [`set_system_chrome`]. | ||
| 75 | +/// | ||
| 76 | +/// Call once per frame on Android **before** [`reserve_system_chrome`]. When the | ||
| 77 | +/// soft keyboard is open, `content_rect` shrinks and the derived bottom inset | ||
| 78 | +/// includes the IME height so layout tracks the real keyboard band — even when | ||
| 79 | +/// the user dismisses the keyboard via the IME close button while a text field | ||
| 80 | +/// still holds focus. | ||
| 81 | +#[cfg(target_os = "android")] | ||
| 82 | +pub fn sync_system_chrome_from_android( | ||
| 83 | + ctx: &Context, | ||
| 84 | + app: &winit::platform::android::activity::AndroidApp, | ||
| 85 | +) { | ||
| 86 | + const NAV_FALLBACK: f32 = 20.0; | ||
| 87 | + /// Minimum extra inset beyond the nav band before treating the IME as visible. | ||
| 88 | + const IME_VISIBLE_THRESHOLD: f32 = 48.0; | ||
| 89 | + | ||
| 90 | + let rect = app.content_rect(); | ||
| 91 | + let ppp = ctx.pixels_per_point().max(0.01); | ||
| 92 | + let screen = ctx.screen_rect(); | ||
| 93 | + | ||
| 94 | + let top = (rect.top as f32 / ppp).max(0.0); | ||
| 95 | + let content_bottom_pt = rect.bottom as f32 / ppp; | ||
| 96 | + let total_bottom = (screen.height() - content_bottom_pt).max(0.0); | ||
| 97 | + | ||
| 98 | + let nav_hint = ctx | ||
| 99 | + .data(|d| d.get_temp::<f32>(Id::new(NAV_HINT_ID))) | ||
| 100 | + .unwrap_or(NAV_FALLBACK); | ||
| 101 | + | ||
| 102 | + // Derive keyboard visibility from content_rect, not wants_keyboard_input(): | ||
| 103 | + // dismissing the IME hides the keyboard but often leaves text focus. | ||
| 104 | + let keyboard_visible = total_bottom > nav_hint + IME_VISIBLE_THRESHOLD; | ||
| 105 | + | ||
| 106 | + if keyboard_visible { | ||
| 107 | + let nav = nav_hint.clamp(0.0, total_bottom); | ||
| 108 | + set_system_chrome( | ||
| 109 | + ctx, | ||
| 110 | + SystemChrome { | ||
| 111 | + top, | ||
| 112 | + nav_bottom: nav, | ||
| 113 | + ime_bottom: (total_bottom - nav).max(0.0), | ||
| 114 | + }, | ||
| 115 | + ); | ||
| 116 | + } else { | ||
| 117 | + ctx.data_mut(|d| d.insert_temp(Id::new(NAV_HINT_ID), total_bottom)); | ||
| 118 | + set_system_chrome(ctx, SystemChrome::from_insets(top, total_bottom)); | ||
| 119 | + } | ||
| 120 | +} | ||
| 121 | + | ||
| 122 | +/// Platform defaults for edge-to-edge drawing. | ||
| 123 | +/// | ||
| 124 | +/// Fallback values are **tight** for modern gesture-nav phones (≈24–28 dp | ||
| 125 | +/// status, ≈16–24 dp gesture handle). Prefer [`set_system_chrome`] or | ||
| 126 | +/// [`sync_system_chrome_from_android`] with measured `content_rect` / | ||
| 127 | +/// WindowInsets when available. | ||
| 128 | +/// | ||
| 129 | +/// When a text field holds focus (`Context::wants_keyboard_input`), the bottom | ||
| 130 | +/// inset grows so bottom bars / compose fields sit **above** the soft keyboard | ||
| 131 | +/// (NativeActivity rarely resizes the GL surface for IME). | ||
| 132 | +pub fn system_chrome(ctx: &Context) -> SystemChrome { | ||
| 133 | + #[cfg(target_os = "android")] | ||
| 134 | + { | ||
| 135 | + let measured = ctx.data(|d| d.get_temp::<SystemChrome>(Id::new(SYSTEM_CHROME_ID))); | ||
| 136 | + const TOP_FALLBACK: f32 = 36.0; | ||
| 137 | + const NAV_FALLBACK: f32 = 20.0; | ||
| 138 | + let top = match measured { | ||
| 139 | + Some(c) if c.top >= 8.0 => c.top, | ||
| 140 | + Some(c) => c.top.max(TOP_FALLBACK), | ||
| 141 | + None => TOP_FALLBACK, | ||
| 142 | + }; | ||
| 143 | + | ||
| 144 | + if let Some(m) = measured { | ||
| 145 | + let chrome = SystemChrome { | ||
| 146 | + top, | ||
| 147 | + nav_bottom: m.nav_bottom.max(0.0), | ||
| 148 | + ime_bottom: m.ime_bottom.max(0.0), | ||
| 149 | + }; | ||
| 150 | + // content_rect / WindowInsets may still be animating with the IME. | ||
| 151 | + if chrome.ime_bottom > NAV_FALLBACK { | ||
| 152 | + ctx.request_repaint(); | ||
| 153 | + } | ||
| 154 | + return chrome; | ||
| 155 | + } | ||
| 156 | + | ||
| 157 | + let mut nav_bottom = NAV_FALLBACK; | ||
| 158 | + let mut ime_bottom = 0.0; | ||
| 159 | + | ||
| 160 | + if ctx.wants_keyboard_input() { | ||
| 161 | + let h = ctx.screen_rect().height(); | ||
| 162 | + let ime_fallback = (h * 0.40).clamp(240.0, h * 0.52); | ||
| 163 | + nav_bottom = NAV_FALLBACK.min(ime_fallback); | ||
| 164 | + ime_bottom = (ime_fallback - nav_bottom).max(0.0); | ||
| 165 | + ctx.request_repaint(); | ||
| 166 | + } | ||
| 167 | + | ||
| 168 | + SystemChrome { | ||
| 169 | + top, | ||
| 170 | + nav_bottom, | ||
| 171 | + ime_bottom, | ||
| 172 | + } | ||
| 173 | + } | ||
| 174 | + #[cfg(not(target_os = "android"))] | ||
| 175 | + { | ||
| 176 | + let _ = ctx; | ||
| 177 | + SystemChrome::ZERO | ||
| 178 | + } | ||
| 179 | +} | ||
| 180 | + | ||
| 181 | +/// Reserve top/bottom strips so **no** subsequent panel or central content can | ||
| 182 | +/// paint under the system status or navigation bars. | ||
| 183 | +/// | ||
| 184 | +/// Call this **once per frame**, before other panels. Safe to call when insets | ||
| 185 | +/// are zero (no-op on desktop). | ||
| 186 | +/// | ||
| 187 | +/// When the soft keyboard is open, the IME reserve does **not** absorb pointer | ||
| 188 | +/// events so swipe typing on the system keyboard is not blocked. Only the nav | ||
| 189 | +/// strip keeps a hover sink so widgets behind the gesture bar cannot steal taps. | ||
| 190 | +pub fn reserve_system_chrome(ctx: &Context, theme: &Theme) { | ||
| 191 | + let chrome = system_chrome(ctx); | ||
| 192 | + if chrome.is_zero() { | ||
| 193 | + return; | ||
| 194 | + } | ||
| 195 | + | ||
| 196 | + let top_band = Frame::new() | ||
| 197 | + .fill(theme.palette.headerbar_bg) | ||
| 198 | + .inner_margin(Margin::ZERO); | ||
| 199 | + let nav_band = Frame::new() | ||
| 200 | + .fill(theme.palette.window_bg) | ||
| 201 | + .inner_margin(Margin::ZERO); | ||
| 202 | + // Transparent — only reserves layout; must not paint over the IME. | ||
| 203 | + let ime_band = Frame::NONE; | ||
| 204 | + | ||
| 205 | + if chrome.top > 0.0 { | ||
| 206 | + egui::TopBottomPanel::top("vidya_system_chrome_top") | ||
| 207 | + .exact_height(chrome.top) | ||
| 208 | + .frame(top_band) | ||
| 209 | + .show_separator_line(false) | ||
| 210 | + .show(ctx, |_ui| {}); | ||
| 211 | + } | ||
| 212 | + | ||
| 213 | + // Bottom panels stack upward: declare nav first (screen edge), then IME. | ||
| 214 | + if chrome.nav_bottom > 0.0 { | ||
| 215 | + egui::TopBottomPanel::bottom("vidya_system_chrome_nav") | ||
| 216 | + .exact_height(chrome.nav_bottom) | ||
| 217 | + .frame(nav_band) | ||
| 218 | + .show_separator_line(false) | ||
| 219 | + .show(ctx, |ui| { | ||
| 220 | + ui.allocate_exact_size(ui.available_size(), Sense::hover()); | ||
| 221 | + }); | ||
| 222 | + } | ||
| 223 | + | ||
| 224 | + if chrome.ime_bottom > 0.0 { | ||
| 225 | + egui::TopBottomPanel::bottom("vidya_system_chrome_ime") | ||
| 226 | + .exact_height(chrome.ime_bottom) | ||
| 227 | + .frame(ime_band) | ||
| 228 | + .show_separator_line(false) | ||
| 229 | + .show(ctx, |ui| { | ||
| 230 | + // Pass touches through to the system keyboard (swipe / glide typing). | ||
| 231 | + ui.allocate_exact_size(ui.available_size(), Sense::empty()); | ||
| 232 | + }); | ||
| 233 | + } | ||
| 234 | +} | ||
| 235 | + | ||
| 236 | +/// Top app header with system status bar already reserved. | ||
| 237 | +/// | ||
| 238 | +/// Preferred entry for shell chrome: apps cannot place title/status text under | ||
| 239 | +/// the clock / indicators. Also reserves the bottom system nav band. | ||
| 240 | +/// | ||
| 241 | +/// ```ignore | ||
| 242 | +/// vidya::top_header(ctx, &theme, |ui| { | ||
| 243 | +/// ui.horizontal(|ui| { | ||
| 244 | +/// vidya::title(ui, &theme, "My App"); | ||
| 245 | +/// }); | ||
| 246 | +/// }); | ||
| 247 | +/// ``` | ||
| 248 | +pub fn top_header(ctx: &Context, theme: &Theme, add_contents: impl FnOnce(&mut Ui)) { | ||
| 249 | + reserve_system_chrome(ctx, theme); | ||
| 250 | + egui::TopBottomPanel::top("vidya_app_header") | ||
| 251 | + .frame(theme.header_frame()) | ||
| 252 | + .show_separator_line(false) | ||
| 253 | + .show(ctx, add_contents); | ||
| 254 | +} | ||
| 255 | + | ||
| 256 | +/// Centered modal-style window with themed card chrome. | ||
| 257 | +/// | ||
| 258 | +/// Defaults: non-collapsible, **resizable**, centered, [`Theme::card_frame`]. | ||
| 259 | +/// Chain `.default_size` / `.min_width` / `.resizable(false)` as needed, then | ||
| 260 | +/// `.show`. | ||
| 261 | +/// | ||
| 262 | +/// ```ignore | ||
| 263 | +/// vidya::dialog("Rename", &theme) | ||
| 264 | +/// .default_width(360.0) | ||
| 265 | +/// .min_width(280.0) | ||
| 266 | +/// .show(ctx, |ui| { /* … */ }); | ||
| 267 | +/// ``` | ||
| 268 | +pub fn dialog<'a>(title: impl Into<WidgetText> + 'a, theme: &Theme) -> Window<'a> { | ||
| 269 | + Window::new(title) | ||
| 270 | + .collapsible(false) | ||
| 271 | + .resizable(true) | ||
| 272 | + .anchor(Align2::CENTER_CENTER, [0.0, 0.0]) | ||
| 273 | + .frame(theme.card_frame()) | ||
| 274 | +} | ||
| 275 | + | ||
| 276 | +#[cfg(test)] | ||
| 277 | +mod tests { | ||
| 278 | + use super::*; | ||
| 279 | + | ||
| 280 | + #[test] | ||
| 281 | + fn system_chrome_bottom_sums_nav_and_ime() { | ||
| 282 | + let c = SystemChrome { | ||
| 283 | + top: 36.0, | ||
| 284 | + nav_bottom: 20.0, | ||
| 285 | + ime_bottom: 400.0, | ||
| 286 | + }; | ||
| 287 | + assert!((c.bottom() - 420.0).abs() < f32::EPSILON); | ||
| 288 | + assert!(!c.is_zero()); | ||
| 289 | + } | ||
| 290 | + | ||
| 291 | + #[test] | ||
| 292 | + fn from_insets_zeroes_ime() { | ||
| 293 | + let c = SystemChrome::from_insets(36.0, 20.0); | ||
| 294 | + assert_eq!(c.ime_bottom, 0.0); | ||
| 295 | + assert_eq!(c.nav_bottom, 20.0); | ||
| 296 | + } | ||
| 297 | +} | ||
added
crates/vidya-core/src/fonts.rs +76 -0 | new file mode 100644 | ||
| @@ -0,0 +1,76 @@ | ||
| 1 | +//! Extra glyph coverage for UI punctuation, math-in-prose, and block art. | |
| 2 | +//! | |
| 3 | +//! egui’s default Ubuntu Light lacks many symbols used in HIG-style copy | |
| 4 | +//! (`→`, `●`, `○`, `▾`/`▴`, en/em dashes, curly quotes, …), common LLM | |
| 5 | +//! math outside `$…$` (`ℝ`, `ⁿ`, `∑`, Greek, …), and box/block elements | |
| 6 | +//! used in Bluesky bios (`█▄▀░`, box drawing). On Android those codepoints | |
| 7 | +//! render as hollow boxes (“tofu”). | |
| 8 | +//! | |
| 9 | +//! [`install_symbol_font`] registers a DejaVu Sans subset as a **fallback** | |
| 10 | +//! so primary text stays Ubuntu, but missing symbols still draw. | |
| 11 | + | |
| 12 | +use egui::{ | |
| 13 | + epaint::text::{FontInsert, FontPriority, InsertFontFamily}, | |
| 14 | + Context, FontData, FontFamily, | |
| 15 | +}; | |
| 16 | + | |
| 17 | +/// Subset of DejaVu Sans covering UI symbols, math-in-prose, and box/block art. | |
| 18 | +/// See `assets/NOTICE` and `scripts/rebuild-symbols-ttf.sh`. | |
| 19 | +static VIDYA_SYMBOLS_TTF: &[u8] = include_bytes!("../assets/vidya-symbols.ttf"); | |
| 20 | + | |
| 21 | +const FONT_NAME: &str = "vidya-symbols"; | |
| 22 | + | |
| 23 | +/// Install the symbol fallback font (idempotent). | |
| 24 | +/// | |
| 25 | +/// Safe to call every frame / from [`crate::apply`]: egui skips re-install when | |
| 26 | +/// the font name is already present. | |
| 27 | +pub fn install_symbol_font(ctx: &Context) { | |
| 28 | + ctx.add_font(FontInsert::new( | |
| 29 | + FONT_NAME, | |
| 30 | + FontData::from_static(VIDYA_SYMBOLS_TTF), | |
| 31 | + vec![ | |
| 32 | + InsertFontFamily { | |
| 33 | + family: FontFamily::Proportional, | |
| 34 | + // After Ubuntu / emoji — only used when those lack the glyph. | |
| 35 | + priority: FontPriority::Lowest, | |
| 36 | + }, | |
| 37 | + InsertFontFamily { | |
| 38 | + family: FontFamily::Monospace, | |
| 39 | + priority: FontPriority::Lowest, | |
| 40 | + }, | |
| 41 | + ], | |
| 42 | + )); | |
| 43 | +} | |
| 44 | + | |
| 45 | +#[cfg(test)] | |
| 46 | +mod tests { | |
| 47 | + use super::{install_symbol_font, VIDYA_SYMBOLS_TTF}; | |
| 48 | + | |
| 49 | + /// Glyphs from a real Bluesky bio (nandi.uk) that previously tofued in Sleek. | |
| 50 | + const BLOCK_ART: &str = "█▄▀░"; | |
| 51 | + | |
| 52 | + #[test] | |
| 53 | + fn symbol_font_covers_block_elements() { | |
| 54 | + assert!( | |
| 55 | + VIDYA_SYMBOLS_TTF.len() > 10_000, | |
| 56 | + "vidya-symbols.ttf looks empty/truncated" | |
| 57 | + ); | |
| 58 | + | |
| 59 | + let ctx = egui::Context::default(); | |
| 60 | + install_symbol_font(&ctx); | |
| 61 | + // Allocate fonts so fallback families are built. | |
| 62 | + ctx.begin_pass(egui::RawInput::default()); | |
| 63 | + | |
| 64 | + let missing: Vec<char> = ctx.fonts(|fonts| { | |
| 65 | + let font_id = egui::FontId::proportional(16.0); | |
| 66 | + BLOCK_ART | |
| 67 | + .chars() | |
| 68 | + .filter(|&c| !fonts.has_glyph(&font_id, c)) | |
| 69 | + .collect() | |
| 70 | + }); | |
| 71 | + assert!( | |
| 72 | + missing.is_empty(), | |
| 73 | + "proportional fallback missing glyphs: {missing:?}" | |
| 74 | + ); | |
| 75 | + } | |
| 76 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,76 @@ | |||
| 1 | +//! Extra glyph coverage for UI punctuation, math-in-prose, and block art. | ||
| 2 | +//! | ||
| 3 | +//! egui’s default Ubuntu Light lacks many symbols used in HIG-style copy | ||
| 4 | +//! (`→`, `●`, `○`, `▾`/`▴`, en/em dashes, curly quotes, …), common LLM | ||
| 5 | +//! math outside `$…$` (`ℝ`, `ⁿ`, `∑`, Greek, …), and box/block elements | ||
| 6 | +//! used in Bluesky bios (`█▄▀░`, box drawing). On Android those codepoints | ||
| 7 | +//! render as hollow boxes (“tofu”). | ||
| 8 | +//! | ||
| 9 | +//! [`install_symbol_font`] registers a DejaVu Sans subset as a **fallback** | ||
| 10 | +//! so primary text stays Ubuntu, but missing symbols still draw. | ||
| 11 | + | ||
| 12 | +use egui::{ | ||
| 13 | + epaint::text::{FontInsert, FontPriority, InsertFontFamily}, | ||
| 14 | + Context, FontData, FontFamily, | ||
| 15 | +}; | ||
| 16 | + | ||
| 17 | +/// Subset of DejaVu Sans covering UI symbols, math-in-prose, and box/block art. | ||
| 18 | +/// See `assets/NOTICE` and `scripts/rebuild-symbols-ttf.sh`. | ||
| 19 | +static VIDYA_SYMBOLS_TTF: &[u8] = include_bytes!("../assets/vidya-symbols.ttf"); | ||
| 20 | + | ||
| 21 | +const FONT_NAME: &str = "vidya-symbols"; | ||
| 22 | + | ||
| 23 | +/// Install the symbol fallback font (idempotent). | ||
| 24 | +/// | ||
| 25 | +/// Safe to call every frame / from [`crate::apply`]: egui skips re-install when | ||
| 26 | +/// the font name is already present. | ||
| 27 | +pub fn install_symbol_font(ctx: &Context) { | ||
| 28 | + ctx.add_font(FontInsert::new( | ||
| 29 | + FONT_NAME, | ||
| 30 | + FontData::from_static(VIDYA_SYMBOLS_TTF), | ||
| 31 | + vec![ | ||
| 32 | + InsertFontFamily { | ||
| 33 | + family: FontFamily::Proportional, | ||
| 34 | + // After Ubuntu / emoji — only used when those lack the glyph. | ||
| 35 | + priority: FontPriority::Lowest, | ||
| 36 | + }, | ||
| 37 | + InsertFontFamily { | ||
| 38 | + family: FontFamily::Monospace, | ||
| 39 | + priority: FontPriority::Lowest, | ||
| 40 | + }, | ||
| 41 | + ], | ||
| 42 | + )); | ||
| 43 | +} | ||
| 44 | + | ||
| 45 | +#[cfg(test)] | ||
| 46 | +mod tests { | ||
| 47 | + use super::{install_symbol_font, VIDYA_SYMBOLS_TTF}; | ||
| 48 | + | ||
| 49 | + /// Glyphs from a real Bluesky bio (nandi.uk) that previously tofued in Sleek. | ||
| 50 | + const BLOCK_ART: &str = "█▄▀░"; | ||
| 51 | + | ||
| 52 | + #[test] | ||
| 53 | + fn symbol_font_covers_block_elements() { | ||
| 54 | + assert!( | ||
| 55 | + VIDYA_SYMBOLS_TTF.len() > 10_000, | ||
| 56 | + "vidya-symbols.ttf looks empty/truncated" | ||
| 57 | + ); | ||
| 58 | + | ||
| 59 | + let ctx = egui::Context::default(); | ||
| 60 | + install_symbol_font(&ctx); | ||
| 61 | + // Allocate fonts so fallback families are built. | ||
| 62 | + ctx.begin_pass(egui::RawInput::default()); | ||
| 63 | + | ||
| 64 | + let missing: Vec<char> = ctx.fonts(|fonts| { | ||
| 65 | + let font_id = egui::FontId::proportional(16.0); | ||
| 66 | + BLOCK_ART | ||
| 67 | + .chars() | ||
| 68 | + .filter(|&c| !fonts.has_glyph(&font_id, c)) | ||
| 69 | + .collect() | ||
| 70 | + }); | ||
| 71 | + assert!( | ||
| 72 | + missing.is_empty(), | ||
| 73 | + "proportional fallback missing glyphs: {missing:?}" | ||
| 74 | + ); | ||
| 75 | + } | ||
| 76 | +} | ||
added
crates/vidya-core/src/hotkeys.rs +113 -0 | new file mode 100644 | ||
| @@ -0,0 +1,113 @@ | ||
| 1 | +//! Keyboard shortcut helpers for egui apps. | |
| 2 | +//! | |
| 3 | +//! Thin wrappers around egui’s input API so call sites stay readable and | |
| 4 | +//! platform labels stay consistent. Uses [`egui::Modifiers::COMMAND`] (Cmd on | |
| 5 | +//! macOS, Ctrl elsewhere) — no global registry or remapping. | |
| 6 | + | |
| 7 | +use egui::{Key, Modifiers, Ui}; | |
| 8 | + | |
| 9 | +/// True when running on macOS (Cmd-based shortcuts / ⌘ labels). | |
| 10 | +#[inline] | |
| 11 | +pub fn is_macos() -> bool { | |
| 12 | + cfg!(target_os = "macos") | |
| 13 | +} | |
| 14 | + | |
| 15 | +/// Platform command modifier glyph for labels: `"⌘"` on macOS, `"Ctrl"` elsewhere. | |
| 16 | +#[inline] | |
| 17 | +pub fn command_glyph() -> &'static str { | |
| 18 | + if is_macos() { | |
| 19 | + "⌘" | |
| 20 | + } else { | |
| 21 | + "Ctrl" | |
| 22 | + } | |
| 23 | +} | |
| 24 | + | |
| 25 | +/// Human-readable label for a command+key chord (`"⌘F"` / `"Ctrl+F"`). | |
| 26 | +pub fn command_shortcut_label(key: Key) -> String { | |
| 27 | + let name = key.name(); | |
| 28 | + if is_macos() { | |
| 29 | + format!("{}{}", command_glyph(), name) | |
| 30 | + } else { | |
| 31 | + format!("{}+{}", command_glyph(), name) | |
| 32 | + } | |
| 33 | +} | |
| 34 | + | |
| 35 | +/// Human-readable label for command+shift+key (`"⇧⌘F"` / `"Ctrl+Shift+F"`). | |
| 36 | +pub fn command_shift_shortcut_label(key: Key) -> String { | |
| 37 | + let name = key.name(); | |
| 38 | + if is_macos() { | |
| 39 | + format!("⇧{}{}", command_glyph(), name) | |
| 40 | + } else { | |
| 41 | + format!("{}+Shift+{}", command_glyph(), name) | |
| 42 | + } | |
| 43 | +} | |
| 44 | + | |
| 45 | +/// Label for the Escape key. | |
| 46 | +#[inline] | |
| 47 | +pub fn escape_label() -> &'static str { | |
| 48 | + "Esc" | |
| 49 | +} | |
| 50 | + | |
| 51 | +/// Consume a command+key chord this frame (Cmd/Ctrl+key). | |
| 52 | +/// | |
| 53 | +/// Returns `true` if the chord was pressed and consumed so it won’t also | |
| 54 | +/// type into a focused text field. | |
| 55 | +pub fn consume_command(ui: &Ui, key: Key) -> bool { | |
| 56 | + ui.input_mut(|i| i.consume_key(Modifiers::COMMAND, key)) | |
| 57 | +} | |
| 58 | + | |
| 59 | +/// Consume a command+shift+key chord this frame. | |
| 60 | +pub fn consume_command_shift(ui: &Ui, key: Key) -> bool { | |
| 61 | + ui.input_mut(|i| i.consume_key(Modifiers::COMMAND | Modifiers::SHIFT, key)) | |
| 62 | +} | |
| 63 | + | |
| 64 | +/// Consume bare Escape this frame. | |
| 65 | +pub fn consume_escape(ui: &Ui) -> bool { | |
| 66 | + ui.input_mut(|i| i.consume_key(Modifiers::NONE, Key::Escape)) | |
| 67 | +} | |
| 68 | + | |
| 69 | +/// Non-consuming peek: true if command+key is pressed this frame. | |
| 70 | +pub fn command_pressed(ui: &Ui, key: Key) -> bool { | |
| 71 | + ui.input(|i| i.key_pressed(key) && i.modifiers.matches_exact(Modifiers::COMMAND)) | |
| 72 | +} | |
| 73 | + | |
| 74 | +#[cfg(test)] | |
| 75 | +mod tests { | |
| 76 | + use super::*; | |
| 77 | + | |
| 78 | + #[test] | |
| 79 | + fn command_glyph_matches_target() { | |
| 80 | + if cfg!(target_os = "macos") { | |
| 81 | + assert_eq!(command_glyph(), "⌘"); | |
| 82 | + assert!(is_macos()); | |
| 83 | + } else { | |
| 84 | + assert_eq!(command_glyph(), "Ctrl"); | |
| 85 | + assert!(!is_macos()); | |
| 86 | + } | |
| 87 | + } | |
| 88 | + | |
| 89 | + #[test] | |
| 90 | + fn command_shortcut_label_formats_f() { | |
| 91 | + let label = command_shortcut_label(Key::F); | |
| 92 | + if cfg!(target_os = "macos") { | |
| 93 | + assert_eq!(label, "⌘F"); | |
| 94 | + } else { | |
| 95 | + assert_eq!(label, "Ctrl+F"); | |
| 96 | + } | |
| 97 | + } | |
| 98 | + | |
| 99 | + #[test] | |
| 100 | + fn command_shift_shortcut_label_formats_f() { | |
| 101 | + let label = command_shift_shortcut_label(Key::F); | |
| 102 | + if cfg!(target_os = "macos") { | |
| 103 | + assert_eq!(label, "⇧⌘F"); | |
| 104 | + } else { | |
| 105 | + assert_eq!(label, "Ctrl+Shift+F"); | |
| 106 | + } | |
| 107 | + } | |
| 108 | + | |
| 109 | + #[test] | |
| 110 | + fn escape_label_is_esc() { | |
| 111 | + assert_eq!(escape_label(), "Esc"); | |
| 112 | + } | |
| 113 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,113 @@ | |||
| 1 | +//! Keyboard shortcut helpers for egui apps. | ||
| 2 | +//! | ||
| 3 | +//! Thin wrappers around egui’s input API so call sites stay readable and | ||
| 4 | +//! platform labels stay consistent. Uses [`egui::Modifiers::COMMAND`] (Cmd on | ||
| 5 | +//! macOS, Ctrl elsewhere) — no global registry or remapping. | ||
| 6 | + | ||
| 7 | +use egui::{Key, Modifiers, Ui}; | ||
| 8 | + | ||
| 9 | +/// True when running on macOS (Cmd-based shortcuts / ⌘ labels). | ||
| 10 | +#[inline] | ||
| 11 | +pub fn is_macos() -> bool { | ||
| 12 | + cfg!(target_os = "macos") | ||
| 13 | +} | ||
| 14 | + | ||
| 15 | +/// Platform command modifier glyph for labels: `"⌘"` on macOS, `"Ctrl"` elsewhere. | ||
| 16 | +#[inline] | ||
| 17 | +pub fn command_glyph() -> &'static str { | ||
| 18 | + if is_macos() { | ||
| 19 | + "⌘" | ||
| 20 | + } else { | ||
| 21 | + "Ctrl" | ||
| 22 | + } | ||
| 23 | +} | ||
| 24 | + | ||
| 25 | +/// Human-readable label for a command+key chord (`"⌘F"` / `"Ctrl+F"`). | ||
| 26 | +pub fn command_shortcut_label(key: Key) -> String { | ||
| 27 | + let name = key.name(); | ||
| 28 | + if is_macos() { | ||
| 29 | + format!("{}{}", command_glyph(), name) | ||
| 30 | + } else { | ||
| 31 | + format!("{}+{}", command_glyph(), name) | ||
| 32 | + } | ||
| 33 | +} | ||
| 34 | + | ||
| 35 | +/// Human-readable label for command+shift+key (`"⇧⌘F"` / `"Ctrl+Shift+F"`). | ||
| 36 | +pub fn command_shift_shortcut_label(key: Key) -> String { | ||
| 37 | + let name = key.name(); | ||
| 38 | + if is_macos() { | ||
| 39 | + format!("⇧{}{}", command_glyph(), name) | ||
| 40 | + } else { | ||
| 41 | + format!("{}+Shift+{}", command_glyph(), name) | ||
| 42 | + } | ||
| 43 | +} | ||
| 44 | + | ||
| 45 | +/// Label for the Escape key. | ||
| 46 | +#[inline] | ||
| 47 | +pub fn escape_label() -> &'static str { | ||
| 48 | + "Esc" | ||
| 49 | +} | ||
| 50 | + | ||
| 51 | +/// Consume a command+key chord this frame (Cmd/Ctrl+key). | ||
| 52 | +/// | ||
| 53 | +/// Returns `true` if the chord was pressed and consumed so it won’t also | ||
| 54 | +/// type into a focused text field. | ||
| 55 | +pub fn consume_command(ui: &Ui, key: Key) -> bool { | ||
| 56 | + ui.input_mut(|i| i.consume_key(Modifiers::COMMAND, key)) | ||
| 57 | +} | ||
| 58 | + | ||
| 59 | +/// Consume a command+shift+key chord this frame. | ||
| 60 | +pub fn consume_command_shift(ui: &Ui, key: Key) -> bool { | ||
| 61 | + ui.input_mut(|i| i.consume_key(Modifiers::COMMAND | Modifiers::SHIFT, key)) | ||
| 62 | +} | ||
| 63 | + | ||
| 64 | +/// Consume bare Escape this frame. | ||
| 65 | +pub fn consume_escape(ui: &Ui) -> bool { | ||
| 66 | + ui.input_mut(|i| i.consume_key(Modifiers::NONE, Key::Escape)) | ||
| 67 | +} | ||
| 68 | + | ||
| 69 | +/// Non-consuming peek: true if command+key is pressed this frame. | ||
| 70 | +pub fn command_pressed(ui: &Ui, key: Key) -> bool { | ||
| 71 | + ui.input(|i| i.key_pressed(key) && i.modifiers.matches_exact(Modifiers::COMMAND)) | ||
| 72 | +} | ||
| 73 | + | ||
| 74 | +#[cfg(test)] | ||
| 75 | +mod tests { | ||
| 76 | + use super::*; | ||
| 77 | + | ||
| 78 | + #[test] | ||
| 79 | + fn command_glyph_matches_target() { | ||
| 80 | + if cfg!(target_os = "macos") { | ||
| 81 | + assert_eq!(command_glyph(), "⌘"); | ||
| 82 | + assert!(is_macos()); | ||
| 83 | + } else { | ||
| 84 | + assert_eq!(command_glyph(), "Ctrl"); | ||
| 85 | + assert!(!is_macos()); | ||
| 86 | + } | ||
| 87 | + } | ||
| 88 | + | ||
| 89 | + #[test] | ||
| 90 | + fn command_shortcut_label_formats_f() { | ||
| 91 | + let label = command_shortcut_label(Key::F); | ||
| 92 | + if cfg!(target_os = "macos") { | ||
| 93 | + assert_eq!(label, "⌘F"); | ||
| 94 | + } else { | ||
| 95 | + assert_eq!(label, "Ctrl+F"); | ||
| 96 | + } | ||
| 97 | + } | ||
| 98 | + | ||
| 99 | + #[test] | ||
| 100 | + fn command_shift_shortcut_label_formats_f() { | ||
| 101 | + let label = command_shift_shortcut_label(Key::F); | ||
| 102 | + if cfg!(target_os = "macos") { | ||
| 103 | + assert_eq!(label, "⇧⌘F"); | ||
| 104 | + } else { | ||
| 105 | + assert_eq!(label, "Ctrl+Shift+F"); | ||
| 106 | + } | ||
| 107 | + } | ||
| 108 | + | ||
| 109 | + #[test] | ||
| 110 | + fn escape_label_is_esc() { | ||
| 111 | + assert_eq!(escape_label(), "Esc"); | ||
| 112 | + } | ||
| 113 | +} | ||
added
crates/vidya-core/src/icons.rs +611 -0 | new file mode 100644 | ||
| @@ -0,0 +1,611 @@ | ||
| 1 | +//! Full-color emoji icons via the complete **Twemoji** 72×72 set. | |
| 2 | +//! | |
| 3 | +//! Any Unicode emoji (including ZWJ sequences, flags, skin tones) is mapped to | |
| 4 | +//! a color PNG by codepoint — not only a hand-picked half-dozen. Unknown or | |
| 5 | +//! non-emoji text falls back to a monochrome label. | |
| 6 | +//! | |
| 7 | +//! ```ignore | |
| 8 | +//! use vidya::{emoji_icon, icon, Icon, Theme}; | |
| 9 | +//! | |
| 10 | +//! emoji_icon(ui, &th, "🚀", 20.0); | |
| 11 | +//! emoji_icon(ui, &th, "👨💻", 20.0); | |
| 12 | +//! icon(ui, &th, Icon::Heart, 20.0); // convenience alias | |
| 13 | +//! ``` | |
| 14 | +//! | |
| 15 | +//! Pack: `assets/emoji/twemoji-72x72.zip` (~3.8k glyphs). | |
| 16 | +//! License: Twemoji CC-BY 4.0 — see `assets/NOTICE`. | |
| 17 | + | |
| 18 | +use std::collections::HashMap; | |
| 19 | +use std::io::{Cursor, Read}; | |
| 20 | +use std::sync::OnceLock; | |
| 21 | + | |
| 22 | +use egui::{ | |
| 23 | + load::SizedTexture, pos2, Align2, Color32, ColorImage, FontId, Id, Image, Rect, Response, | |
| 24 | + Sense, Stroke, TextureHandle, TextureOptions, Ui, Vec2, | |
| 25 | +}; | |
| 26 | + | |
| 27 | +use crate::Theme; | |
| 28 | + | |
| 29 | +/// Embedded Twemoji 72×72 pack (ZIP_STORED PNGs). | |
| 30 | +static TWEMOJI_ZIP: &[u8] = include_bytes!("../assets/emoji/twemoji-72x72.zip"); | |
| 31 | + | |
| 32 | +/// Filename → PNG bytes, built once on first lookup. | |
| 33 | +fn twemoji_pack() -> &'static HashMap<String, Vec<u8>> { | |
| 34 | + static PACK: OnceLock<HashMap<String, Vec<u8>>> = OnceLock::new(); | |
| 35 | + PACK.get_or_init(|| { | |
| 36 | + let mut map = HashMap::new(); | |
| 37 | + let Ok(mut archive) = zip::ZipArchive::new(Cursor::new(TWEMOJI_ZIP)) else { | |
| 38 | + return map; | |
| 39 | + }; | |
| 40 | + for i in 0..archive.len() { | |
| 41 | + let Ok(mut file) = archive.by_index(i) else { | |
| 42 | + continue; | |
| 43 | + }; | |
| 44 | + let name = file.name().to_string(); | |
| 45 | + if !name.ends_with(".png") { | |
| 46 | + continue; | |
| 47 | + } | |
| 48 | + // Skip directory entries / paths with separators (zip-slip). | |
| 49 | + if name.contains('/') || name.contains('\\') { | |
| 50 | + continue; | |
| 51 | + } | |
| 52 | + let mut buf = Vec::new(); | |
| 53 | + if file.read_to_end(&mut buf).is_ok() { | |
| 54 | + map.insert(name, buf); | |
| 55 | + } | |
| 56 | + } | |
| 57 | + map | |
| 58 | + }) | |
| 59 | +} | |
| 60 | + | |
| 61 | +/// Named shortcuts for common reactions + UI chrome. | |
| 62 | +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] | |
| 63 | +pub enum Icon { | |
| 64 | + ThumbsUp, | |
| 65 | + ThumbsDown, | |
| 66 | + Heart, | |
| 67 | + Laugh, | |
| 68 | + Surprised, | |
| 69 | + Frown, | |
| 70 | + /// Drawn stroke — not a Twemoji glyph. | |
| 71 | + Plus, | |
| 72 | + /// Drawn stroke — overlapping pages (copy / duplicate). | |
| 73 | + Copy, | |
| 74 | +} | |
| 75 | + | |
| 76 | +impl Icon { | |
| 77 | + pub const EMOJI: &'static [Icon] = &[ | |
| 78 | + Icon::ThumbsUp, | |
| 79 | + Icon::Heart, | |
| 80 | + Icon::Laugh, | |
| 81 | + Icon::Surprised, | |
| 82 | + Icon::Frown, | |
| 83 | + Icon::ThumbsDown, | |
| 84 | + ]; | |
| 85 | + | |
| 86 | + pub const ALL: &'static [Icon] = &[ | |
| 87 | + Icon::ThumbsUp, | |
| 88 | + Icon::Heart, | |
| 89 | + Icon::Laugh, | |
| 90 | + Icon::Surprised, | |
| 91 | + Icon::Frown, | |
| 92 | + Icon::ThumbsDown, | |
| 93 | + Icon::Plus, | |
| 94 | + Icon::Copy, | |
| 95 | + ]; | |
| 96 | + | |
| 97 | + /// True when this icon is stroke-drawn (not a Twemoji bitmap). | |
| 98 | + pub fn is_stroke(self) -> bool { | |
| 99 | + matches!(self, Icon::Plus | Icon::Copy) | |
| 100 | + } | |
| 101 | + | |
| 102 | + pub fn emoji(self) -> &'static str { | |
| 103 | + match self { | |
| 104 | + Icon::ThumbsUp => "👍", | |
| 105 | + Icon::ThumbsDown => "👎", | |
| 106 | + Icon::Heart => "❤️", | |
| 107 | + Icon::Laugh => "😂", | |
| 108 | + Icon::Surprised => "😮", | |
| 109 | + Icon::Frown => "😢", | |
| 110 | + Icon::Plus => "+", | |
| 111 | + Icon::Copy => "⧉", | |
| 112 | + } | |
| 113 | + } | |
| 114 | +} | |
| 115 | + | |
| 116 | +/// Strip only pure presentation noise (kept for callers / labels). | |
| 117 | +pub fn normalize_emoji(emoji: &str) -> String { | |
| 118 | + emoji | |
| 119 | + .chars() | |
| 120 | + .filter(|&c| c != '\u{FE0E}' && c != '\u{FE0F}') | |
| 121 | + .collect() | |
| 122 | +} | |
| 123 | + | |
| 124 | +/// Map a well-known short emoji to [`Icon`] (convenience). Prefer [`emoji_icon`] | |
| 125 | +/// for arbitrary reactions — that covers the full Twemoji set. | |
| 126 | +pub fn icon_for_emoji(emoji: &str) -> Option<Icon> { | |
| 127 | + let key: String = emoji | |
| 128 | + .chars() | |
| 129 | + .filter(|&c| { | |
| 130 | + c != '\u{FE0E}' | |
| 131 | + && c != '\u{FE0F}' | |
| 132 | + && c != '\u{200D}' | |
| 133 | + && !matches!(c, '\u{1F3FB}'..='\u{1F3FF}') | |
| 134 | + }) | |
| 135 | + .collect(); | |
| 136 | + match key.as_str() { | |
| 137 | + "👍" | "+1" => Some(Icon::ThumbsUp), | |
| 138 | + "👎" | "-1" => Some(Icon::ThumbsDown), | |
| 139 | + "❤" | "♥" => Some(Icon::Heart), | |
| 140 | + "😂" | "😄" | "😆" | "🤣" => Some(Icon::Laugh), | |
| 141 | + "😮" | "😯" | "😲" => Some(Icon::Surprised), | |
| 142 | + "😢" | "😞" | "🙁" | "☹" | "😭" => Some(Icon::Frown), | |
| 143 | + _ => None, | |
| 144 | + } | |
| 145 | +} | |
| 146 | + | |
| 147 | +/// True when we have a color Twemoji bitmap for this string. | |
| 148 | +pub fn has_emoji_icon(emoji: &str) -> bool { | |
| 149 | + twemoji_png(emoji).is_some() | |
| 150 | +} | |
| 151 | + | |
| 152 | +/// How many glyphs are in the embedded pack (for demos / diagnostics). | |
| 153 | +pub fn emoji_pack_len() -> usize { | |
| 154 | + twemoji_pack().len() | |
| 155 | +} | |
| 156 | + | |
| 157 | +// ── Twemoji codepoint keys (matches twemoji.js grabTheRightIcon) ──────────── | |
| 158 | + | |
| 159 | +const ZWJ: char = '\u{200D}'; | |
| 160 | +const VS16: char = '\u{FE0F}'; | |
| 161 | +const VS15: char = '\u{FE0E}'; | |
| 162 | + | |
| 163 | +/// Twemoji file stem: lowercase hex codepoints joined by `-`. | |
| 164 | +/// | |
| 165 | +/// Rule from twemoji: if the sequence has **no** ZWJ, strip FE0F/FE0E; | |
| 166 | +/// if it has ZWJ, keep variation selectors (filenames include `fe0f`). | |
| 167 | +pub fn twemoji_key(emoji: &str) -> String { | |
| 168 | + let has_zwj = emoji.contains(ZWJ); | |
| 169 | + let chars = emoji.chars().filter(|&c| { | |
| 170 | + if has_zwj { | |
| 171 | + true | |
| 172 | + } else { | |
| 173 | + c != VS16 && c != VS15 | |
| 174 | + } | |
| 175 | + }); | |
| 176 | + chars | |
| 177 | + .map(|c| format!("{:x}", c as u32)) | |
| 178 | + .collect::<Vec<_>>() | |
| 179 | + .join("-") | |
| 180 | +} | |
| 181 | + | |
| 182 | +fn strip_skin_tones(emoji: &str) -> String { | |
| 183 | + emoji | |
| 184 | + .chars() | |
| 185 | + .filter(|c| !matches!(c, '\u{1F3FB}'..='\u{1F3FF}')) | |
| 186 | + .collect() | |
| 187 | +} | |
| 188 | + | |
| 189 | +/// Candidate Twemoji stems, most specific first. | |
| 190 | +fn twemoji_key_candidates(emoji: &str) -> Vec<String> { | |
| 191 | + let mut out = Vec::new(); | |
| 192 | + let mut push = |s: String| { | |
| 193 | + if !s.is_empty() && !out.contains(&s) { | |
| 194 | + out.push(s); | |
| 195 | + } | |
| 196 | + }; | |
| 197 | + | |
| 198 | + let trimmed = emoji.trim(); | |
| 199 | + if trimmed.is_empty() { | |
| 200 | + return out; | |
| 201 | + } | |
| 202 | + | |
| 203 | + push(twemoji_key(trimmed)); | |
| 204 | + | |
| 205 | + // Always also try VS-stripped form (covers assets that omit FE0F). | |
| 206 | + let no_vs: String = trimmed | |
| 207 | + .chars() | |
| 208 | + .filter(|&c| c != VS16 && c != VS15) | |
| 209 | + .collect(); | |
| 210 | + push(twemoji_key(&no_vs)); | |
| 211 | + // Force “no ZWJ rule” key on the raw string by stripping VS. | |
| 212 | + push( | |
| 213 | + no_vs | |
| 214 | + .chars() | |
| 215 | + .map(|c| format!("{:x}", c as u32)) | |
| 216 | + .collect::<Vec<_>>() | |
| 217 | + .join("-"), | |
| 218 | + ); | |
| 219 | + | |
| 220 | + // Skin-tone fallback → base glyph. | |
| 221 | + let no_skin = strip_skin_tones(trimmed); | |
| 222 | + if no_skin != trimmed { | |
| 223 | + push(twemoji_key(&no_skin)); | |
| 224 | + let no_skin_vs: String = no_skin | |
| 225 | + .chars() | |
| 226 | + .filter(|&c| c != VS16 && c != VS15) | |
| 227 | + .collect(); | |
| 228 | + push(twemoji_key(&no_skin_vs)); | |
| 229 | + } | |
| 230 | + | |
| 231 | + // First extended grapheme-ish: take until second ZWJ-less “cluster” — for | |
| 232 | + // multi-emoji paste, try the whole string then the first scalar sequence. | |
| 233 | + // If still missing, try each scalar / ZWJ segment from the start. | |
| 234 | + if trimmed.chars().count() > 1 { | |
| 235 | + // Leading base only (first non-VS non-skin char + optional VS). | |
| 236 | + if let Some(first) = trimmed.chars().find(|c| { | |
| 237 | + *c != VS16 && *c != VS15 && !matches!(c, '\u{1F3FB}'..='\u{1F3FF}') && *c != ZWJ | |
| 238 | + }) { | |
| 239 | + push(format!("{:x}", first as u32)); | |
| 240 | + } | |
| 241 | + } | |
| 242 | + | |
| 243 | + out | |
| 244 | +} | |
| 245 | + | |
| 246 | +/// PNG bytes for an emoji, if present in the pack. | |
| 247 | +fn twemoji_png(emoji: &str) -> Option<&'static [u8]> { | |
| 248 | + let pack = twemoji_pack(); | |
| 249 | + for key in twemoji_key_candidates(emoji) { | |
| 250 | + let name = format!("{key}.png"); | |
| 251 | + if let Some(bytes) = pack.get(&name) { | |
| 252 | + // Safe: pack is 'static, values live for process lifetime. | |
| 253 | + return Some(bytes.as_slice()); | |
| 254 | + } | |
| 255 | + } | |
| 256 | + None | |
| 257 | +} | |
| 258 | + | |
| 259 | +// ── painting ──────────────────────────────────────────────────────────────── | |
| 260 | + | |
| 261 | +/// Paint a named icon at `size`×`size`. | |
| 262 | +pub fn icon(ui: &mut Ui, theme: &Theme, icon: Icon, size: f32) -> Response { | |
| 263 | + icon_colored(ui, theme.palette.text, icon, size) | |
| 264 | +} | |
| 265 | + | |
| 266 | +/// Like [`icon`]; `color` only affects stroke icons ([`Icon::Plus`], [`Icon::Copy`]). | |
| 267 | +pub fn icon_colored(ui: &mut Ui, color: Color32, icon: Icon, size: f32) -> Response { | |
| 268 | + let size = size.max(8.0); | |
| 269 | + match icon { | |
| 270 | + Icon::Plus | Icon::Copy => paint_stroke_icon(ui, color, icon, size), | |
| 271 | + other => { | |
| 272 | + let (rect, response) = ui.allocate_exact_size(Vec2::splat(size), Sense::hover()); | |
| 273 | + paint_emoji_in(ui, rect, other.emoji(), color); | |
| 274 | + response | |
| 275 | + } | |
| 276 | + } | |
| 277 | +} | |
| 278 | + | |
| 279 | +/// Paint any emoji as a color Twemoji when available; else text fallback. | |
| 280 | +pub fn emoji_icon(ui: &mut Ui, theme: &Theme, emoji: &str, size: f32) -> Response { | |
| 281 | + emoji_icon_colored(ui, theme, theme.palette.text, emoji, size) | |
| 282 | +} | |
| 283 | + | |
| 284 | +/// Like [`emoji_icon`]; `color` is only for text / Plus fallback. | |
| 285 | +pub fn emoji_icon_colored( | |
| 286 | + ui: &mut Ui, | |
| 287 | + theme: &Theme, | |
| 288 | + color: Color32, | |
| 289 | + emoji: &str, | |
| 290 | + size: f32, | |
| 291 | +) -> Response { | |
| 292 | + let size = size.max(8.0); | |
| 293 | + let (rect, response) = ui.allocate_exact_size(Vec2::splat(size), Sense::hover()); | |
| 294 | + paint_emoji_in(ui, rect, emoji, color); | |
| 295 | + let _ = theme; | |
| 296 | + response | |
| 297 | +} | |
| 298 | + | |
| 299 | +/// Draw a stroke icon ([`Icon::Plus`], [`Icon::Copy`]) into `rect`. | |
| 300 | +/// Emoji icons need [`paint_emoji_in`] / [`paint_icon_in`]. | |
| 301 | +pub fn paint_icon(painter: &egui::Painter, rect: Rect, icon: Icon, color: Color32) { | |
| 302 | + let s = rect.width().min(rect.height()); | |
| 303 | + let c = rect.center(); | |
| 304 | + let w = (s * 0.12).clamp(1.5, 2.5); | |
| 305 | + let stroke = Stroke::new(w, color); | |
| 306 | + match icon { | |
| 307 | + Icon::Plus => { | |
| 308 | + let arm = s * 0.32; | |
| 309 | + painter.line_segment([pos2(c.x - arm, c.y), pos2(c.x + arm, c.y)], stroke); | |
| 310 | + painter.line_segment([pos2(c.x, c.y - arm), pos2(c.x, c.y + arm)], stroke); | |
| 311 | + } | |
| 312 | + Icon::Copy => { | |
| 313 | + // Two overlapping pages — classic “copy” glyph (stroke only). | |
| 314 | + let page_w = s * 0.38; | |
| 315 | + let page_h = s * 0.46; | |
| 316 | + let r = (s * 0.08).clamp(0.5, 2.5); | |
| 317 | + let dx = s * 0.11; | |
| 318 | + let dy = s * 0.11; | |
| 319 | + let back = Rect::from_center_size(pos2(c.x - dx, c.y - dy), Vec2::new(page_w, page_h)); | |
| 320 | + let front = Rect::from_center_size(pos2(c.x + dx, c.y + dy), Vec2::new(page_w, page_h)); | |
| 321 | + painter.rect_stroke(back, r, stroke, egui::StrokeKind::Inside); | |
| 322 | + painter.rect_stroke(front, r, stroke, egui::StrokeKind::Inside); | |
| 323 | + } | |
| 324 | + _ => {} | |
| 325 | + } | |
| 326 | +} | |
| 327 | + | |
| 328 | +/// Draw a named [`Icon`] into `rect`. | |
| 329 | +pub fn paint_icon_in(ui: &Ui, rect: Rect, icon: Icon, color: Color32) { | |
| 330 | + match icon { | |
| 331 | + Icon::Plus | Icon::Copy => paint_icon(ui.painter(), rect, icon, color), | |
| 332 | + other => paint_emoji_in(ui, rect, other.emoji(), color), | |
| 333 | + } | |
| 334 | +} | |
| 335 | + | |
| 336 | +/// Draw any emoji into `rect` (color Twemoji or text fallback). | |
| 337 | +pub fn paint_emoji_in(ui: &Ui, rect: Rect, emoji: &str, color: Color32) { | |
| 338 | + if !ui.is_rect_visible(rect) { | |
| 339 | + return; | |
| 340 | + } | |
| 341 | + if let Some(tex) = load_emoji_texture(ui.ctx(), emoji) { | |
| 342 | + Image::from_texture(SizedTexture::new(tex.id(), tex.size_vec2())) | |
| 343 | + .fit_to_exact_size(rect.size()) | |
| 344 | + .paint_at(ui, rect); | |
| 345 | + return; | |
| 346 | + } | |
| 347 | + // Fallback: raw text (may be monochrome Noto / tofu). | |
| 348 | + let shown = normalize_emoji(emoji); | |
| 349 | + let size = rect.width().min(rect.height()); | |
| 350 | + ui.painter().text( | |
| 351 | + rect.center(), | |
| 352 | + Align2::CENTER_CENTER, | |
| 353 | + if shown.is_empty() { emoji } else { &shown }, | |
| 354 | + FontId::proportional((size * 0.85).max(10.0)), | |
| 355 | + color, | |
| 356 | + ); | |
| 357 | +} | |
| 358 | + | |
| 359 | +/// Themed reaction chip: color emoji + optional count, at the caption size. | |
| 360 | +pub fn reaction_chip( | |
| 361 | + ui: &mut Ui, | |
| 362 | + theme: &Theme, | |
| 363 | + emoji: &str, | |
| 364 | + count: usize, | |
| 365 | + mine: bool, | |
| 366 | +) -> Response { | |
| 367 | + let icon_size = (theme.type_scale.caption * 1.25).max(16.0); | |
| 368 | + reaction_chip_sized(ui, theme, emoji, count, mine, icon_size) | |
| 369 | +} | |
| 370 | + | |
| 371 | +/// Like [`reaction_chip`], with the glyph drawn at `icon_size` points. | |
| 372 | +/// | |
| 373 | +/// The pill around it is sized from the glyph rather than from the theme, so a | |
| 374 | +/// small chip is small all through instead of a small picture adrift in a | |
| 375 | +/// caption-sized pill. | |
| 376 | +pub fn reaction_chip_sized( | |
| 377 | + ui: &mut Ui, | |
| 378 | + theme: &Theme, | |
| 379 | + emoji: &str, | |
| 380 | + count: usize, | |
| 381 | + mine: bool, | |
| 382 | + icon_size: f32, | |
| 383 | +) -> Response { | |
| 384 | + let p = &theme.palette; | |
| 385 | + let icon_size = icon_size.max(8.0); | |
| 386 | + let fill = if mine { | |
| 387 | + p.accent.gamma_multiply(0.35) | |
| 388 | + } else { | |
| 389 | + p.headerbar_bg | |
| 390 | + }; | |
| 391 | + let border = if mine { | |
| 392 | + Stroke::new(1.0_f32, p.accent.gamma_multiply(0.7)) | |
| 393 | + } else { | |
| 394 | + Stroke::new(1.0_f32, p.border_soft) | |
| 395 | + }; | |
| 396 | + let inner = egui::Frame::new() | |
| 397 | + .fill(fill) | |
| 398 | + .stroke(border) | |
| 399 | + .corner_radius(icon_size * 0.75) | |
| 400 | + .inner_margin(egui::Margin::symmetric( | |
| 401 | + (icon_size * 0.5) as i8, | |
| 402 | + (icon_size * 0.2) as i8, | |
| 403 | + )) | |
| 404 | + .show(ui, |ui| { | |
| 405 | + ui.horizontal(|ui| { | |
| 406 | + // Spacing is allocated by hand below, so that a chip showing a | |
| 407 | + // number and one that is not can be laid out to the same width | |
| 408 | + // without the gap between items being counted a different | |
| 409 | + // number of times in each. | |
| 410 | + ui.spacing_mut().item_spacing.x = 0.0; | |
| 411 | + let text_size = theme.type_scale.caption.min(icon_size); | |
| 412 | + let gap = (icon_size * 0.25).max(2.0); | |
| 413 | + // Two digits, which is as far as a reaction count usually | |
| 414 | + // goes, so one, nine and ninety-nine are all the same chip: a | |
| 415 | + // chip that grew when a second person arrived would shuffle | |
| 416 | + // every chip beside it along the row, and a row of reactions is | |
| 417 | + // something people aim at. | |
| 418 | + let slot = text_size * 1.2; | |
| 419 | + let space = |ui: &mut Ui, w: f32| { | |
| 420 | + if w > 0.0 { | |
| 421 | + ui.allocate_exact_size(Vec2::new(w, icon_size), Sense::hover()); | |
| 422 | + } | |
| 423 | + }; | |
| 424 | + match count { | |
| 425 | + // Not a reaction but the offer of one — what a picker is | |
| 426 | + // made of. Nothing to count, so nothing is set aside for a | |
| 427 | + // count and the glyph has the pill to itself. | |
| 428 | + 0 => { | |
| 429 | + emoji_icon(ui, theme, emoji, icon_size); | |
| 430 | + } | |
| 431 | + // One reactor, and the "1" goes without saying. The room it | |
| 432 | + // would have taken is split either side of the glyph rather | |
| 433 | + // than left hanging off the end: the chip is the width of | |
| 434 | + // one that does show a number, and still centred. | |
| 435 | + 1 => { | |
| 436 | + space(ui, (gap + slot) / 2.0); | |
| 437 | + emoji_icon(ui, theme, emoji, icon_size); | |
| 438 | + space(ui, (gap + slot) / 2.0); | |
| 439 | + } | |
| 440 | + n => { | |
| 441 | + emoji_icon(ui, theme, emoji, icon_size); | |
| 442 | + space(ui, gap); | |
| 443 | + let (rect, _) = | |
| 444 | + ui.allocate_exact_size(Vec2::new(slot, icon_size), Sense::hover()); | |
| 445 | + ui.painter().text( | |
| 446 | + rect.center(), | |
| 447 | + Align2::CENTER_CENTER, | |
| 448 | + n.to_string(), | |
| 449 | + FontId::proportional(text_size), | |
| 450 | + p.text, | |
| 451 | + ); | |
| 452 | + } | |
| 453 | + } | |
| 454 | + }); | |
| 455 | + }); | |
| 456 | + // A frame's own response only senses hover, so a chip built from one is | |
| 457 | + // unclickable however it looks — and a reaction chip is a button: clicking | |
| 458 | + // it is how a reaction is put on or taken off. `interact` is what gives the | |
| 459 | + // rect the frame occupies a click to report. | |
| 460 | + let response = inner.response.interact(Sense::click()); | |
| 461 | + if response.hovered() { | |
| 462 | + ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); | |
| 463 | + } | |
| 464 | + response | |
| 465 | +} | |
| 466 | + | |
| 467 | +// ── texture cache ─────────────────────────────────────────────────────────── | |
| 468 | + | |
| 469 | +fn load_emoji_texture(ctx: &egui::Context, emoji: &str) -> Option<TextureHandle> { | |
| 470 | + let key = twemoji_key_candidates(emoji).into_iter().next()?; | |
| 471 | + // Cache by resolved pack key so ❤️ and ❤ share a texture when they hit the | |
| 472 | + // same file; we re-resolve via png bytes identity below. | |
| 473 | + let png = twemoji_png(emoji)?; | |
| 474 | + // Stable id from the actual file we loaded (content address via key candidates). | |
| 475 | + let file_key = twemoji_key_candidates(emoji) | |
| 476 | + .into_iter() | |
| 477 | + .find(|k| twemoji_pack().contains_key(&format!("{k}.png"))) | |
| 478 | + .unwrap_or(key); | |
| 479 | + let cache_id = Id::new(("vidya/twemoji", file_key.as_str())); | |
| 480 | + | |
| 481 | + if let Some(tex) = ctx.data(|d| d.get_temp::<TextureHandle>(cache_id)) { | |
| 482 | + return Some(tex); | |
| 483 | + } | |
| 484 | + | |
| 485 | + let image = decode_png_rgba(png)?; | |
| 486 | + let handle = ctx.load_texture( | |
| 487 | + format!("vidya/twemoji/{file_key}"), | |
| 488 | + image, | |
| 489 | + TextureOptions::LINEAR, | |
| 490 | + ); | |
| 491 | + ctx.data_mut(|d| d.insert_temp(cache_id, handle.clone())); | |
| 492 | + Some(handle) | |
| 493 | +} | |
| 494 | + | |
| 495 | +fn decode_png_rgba(bytes: &[u8]) -> Option<ColorImage> { | |
| 496 | + let mut decoder = png::Decoder::new(Cursor::new(bytes)); | |
| 497 | + decoder.set_transformations(png::Transformations::EXPAND | png::Transformations::ALPHA); | |
| 498 | + let mut reader = decoder.read_info().ok()?; | |
| 499 | + let mut buf = vec![0; reader.output_buffer_size()]; | |
| 500 | + let info = reader.next_frame(&mut buf).ok()?; | |
| 501 | + let w = info.width as usize; | |
| 502 | + let h = info.height as usize; | |
| 503 | + let raw = &buf[..info.buffer_size()]; | |
| 504 | + let rgba: Vec<u8> = match info.color_type { | |
| 505 | + png::ColorType::Rgba => raw.to_vec(), | |
| 506 | + png::ColorType::Rgb => { | |
| 507 | + let mut out = Vec::with_capacity(w * h * 4); | |
| 508 | + for chunk in raw.chunks_exact(3) { | |
| 509 | + out.extend_from_slice(&[chunk[0], chunk[1], chunk[2], 255]); | |
| 510 | + } | |
| 511 | + out | |
| 512 | + } | |
| 513 | + _ => return None, | |
| 514 | + }; | |
| 515 | + if rgba.len() != w * h * 4 { | |
| 516 | + return None; | |
| 517 | + } | |
| 518 | + Some(ColorImage::from_rgba_unmultiplied([w, h], &rgba)) | |
| 519 | +} | |
| 520 | + | |
| 521 | +fn paint_stroke_icon(ui: &mut Ui, color: Color32, icon: Icon, size: f32) -> Response { | |
| 522 | + let (rect, response) = ui.allocate_exact_size(Vec2::splat(size), Sense::hover()); | |
| 523 | + if ui.is_rect_visible(rect) { | |
| 524 | + paint_icon(ui.painter(), rect, icon, color); | |
| 525 | + } | |
| 526 | + response | |
| 527 | +} | |
| 528 | + | |
| 529 | +#[cfg(test)] | |
| 530 | +mod tests { | |
| 531 | + use super::*; | |
| 532 | + | |
| 533 | + #[test] | |
| 534 | + fn pack_is_populated() { | |
| 535 | + let n = emoji_pack_len(); | |
| 536 | + assert!(n > 3000, "expected full Twemoji set, got {n}"); | |
| 537 | + } | |
| 538 | + | |
| 539 | + #[test] | |
| 540 | + fn keys_match_twemoji_filenames() { | |
| 541 | + assert_eq!(twemoji_key("👍"), "1f44d"); | |
| 542 | + assert_eq!(twemoji_key("❤️"), "2764"); // VS16 stripped (no ZWJ) | |
| 543 | + assert_eq!(twemoji_key("😂"), "1f602"); | |
| 544 | + assert_eq!(twemoji_key("🚀"), "1f680"); | |
| 545 | + // ZWJ sequence keeps fe0f when present in input | |
| 546 | + let technologist = "👨\u{200D}💻"; | |
| 547 | + assert_eq!(twemoji_key(technologist), "1f468-200d-1f4bb"); | |
| 548 | + } | |
| 549 | + | |
| 550 | + /// A chip nobody can click is a picture of a button. The frame it is built | |
| 551 | + /// from only senses hover on its own, so this is the regression that | |
| 552 | + /// matters: reacting is a click on this widget. | |
| 553 | + #[test] | |
| 554 | + fn chip_senses_clicks() { | |
| 555 | + let theme = Theme::dark(); | |
| 556 | + let ctx = egui::Context::default(); | |
| 557 | + let mut sense = Sense::hover(); | |
| 558 | + let _ = ctx.run(Default::default(), |ctx| { | |
| 559 | + egui::CentralPanel::default().show(ctx, |ui| { | |
| 560 | + sense = reaction_chip(ui, &theme, "\u{1f44d}", 2, false).sense; | |
| 561 | + }); | |
| 562 | + }); | |
| 563 | + assert!(sense.senses_click(), "reaction chip does not sense clicks"); | |
| 564 | + } | |
| 565 | + | |
| 566 | + #[test] | |
| 567 | + fn resolves_common_and_rare() { | |
| 568 | + for e in [ | |
| 569 | + "👍", "❤️", "❤", "😂", "😮", "😢", "👎", "🚀", "🎉", "🔥", "✨", "👀", "💯", "🙏", | |
| 570 | + "😎", "🤯", "🏳️", "🏴", | |
| 571 | + ] { | |
| 572 | + assert!( | |
| 573 | + has_emoji_icon(e), | |
| 574 | + "missing Twemoji for {e:?} key={:?}", | |
| 575 | + twemoji_key_candidates(e) | |
| 576 | + ); | |
| 577 | + } | |
| 578 | + } | |
| 579 | + | |
| 580 | + #[test] | |
| 581 | + fn skin_tone_falls_back_to_base() { | |
| 582 | + // 👍🏻 → base 👍 asset | |
| 583 | + assert!(has_emoji_icon("👍🏻")); | |
| 584 | + } | |
| 585 | + | |
| 586 | + #[test] | |
| 587 | + fn zwj_sequence() { | |
| 588 | + assert!(has_emoji_icon("👨💻"), "technologist ZWJ"); | |
| 589 | + } | |
| 590 | + | |
| 591 | + #[test] | |
| 592 | + fn unknown_text_has_no_icon() { | |
| 593 | + assert!(!has_emoji_icon("hello")); | |
| 594 | + assert!(!has_emoji_icon("")); | |
| 595 | + } | |
| 596 | + | |
| 597 | + #[test] | |
| 598 | + fn icon_shortcuts() { | |
| 599 | + assert_eq!(icon_for_emoji("👍"), Some(Icon::ThumbsUp)); | |
| 600 | + assert_eq!(icon_for_emoji("❤️"), Some(Icon::Heart)); | |
| 601 | + } | |
| 602 | + | |
| 603 | + #[test] | |
| 604 | + fn pngs_decode() { | |
| 605 | + for e in ["👍", "❤️", "😂", "🚀", "👨💻"] { | |
| 606 | + let png = twemoji_png(e).unwrap_or_else(|| panic!("no png for {e}")); | |
| 607 | + let img = decode_png_rgba(png).unwrap_or_else(|| panic!("decode {e}")); | |
| 608 | + assert_eq!(img.size, [72, 72]); | |
| 609 | + } | |
| 610 | + } | |
| 611 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,611 @@ | |||
| 1 | +//! Full-color emoji icons via the complete **Twemoji** 72×72 set. | ||
| 2 | +//! | ||
| 3 | +//! Any Unicode emoji (including ZWJ sequences, flags, skin tones) is mapped to | ||
| 4 | +//! a color PNG by codepoint — not only a hand-picked half-dozen. Unknown or | ||
| 5 | +//! non-emoji text falls back to a monochrome label. | ||
| 6 | +//! | ||
| 7 | +//! ```ignore | ||
| 8 | +//! use vidya::{emoji_icon, icon, Icon, Theme}; | ||
| 9 | +//! | ||
| 10 | +//! emoji_icon(ui, &th, "🚀", 20.0); | ||
| 11 | +//! emoji_icon(ui, &th, "👨💻", 20.0); | ||
| 12 | +//! icon(ui, &th, Icon::Heart, 20.0); // convenience alias | ||
| 13 | +//! ``` | ||
| 14 | +//! | ||
| 15 | +//! Pack: `assets/emoji/twemoji-72x72.zip` (~3.8k glyphs). | ||
| 16 | +//! License: Twemoji CC-BY 4.0 — see `assets/NOTICE`. | ||
| 17 | + | ||
| 18 | +use std::collections::HashMap; | ||
| 19 | +use std::io::{Cursor, Read}; | ||
| 20 | +use std::sync::OnceLock; | ||
| 21 | + | ||
| 22 | +use egui::{ | ||
| 23 | + load::SizedTexture, pos2, Align2, Color32, ColorImage, FontId, Id, Image, Rect, Response, | ||
| 24 | + Sense, Stroke, TextureHandle, TextureOptions, Ui, Vec2, | ||
| 25 | +}; | ||
| 26 | + | ||
| 27 | +use crate::Theme; | ||
| 28 | + | ||
| 29 | +/// Embedded Twemoji 72×72 pack (ZIP_STORED PNGs). | ||
| 30 | +static TWEMOJI_ZIP: &[u8] = include_bytes!("../assets/emoji/twemoji-72x72.zip"); | ||
| 31 | + | ||
| 32 | +/// Filename → PNG bytes, built once on first lookup. | ||
| 33 | +fn twemoji_pack() -> &'static HashMap<String, Vec<u8>> { | ||
| 34 | + static PACK: OnceLock<HashMap<String, Vec<u8>>> = OnceLock::new(); | ||
| 35 | + PACK.get_or_init(|| { | ||
| 36 | + let mut map = HashMap::new(); | ||
| 37 | + let Ok(mut archive) = zip::ZipArchive::new(Cursor::new(TWEMOJI_ZIP)) else { | ||
| 38 | + return map; | ||
| 39 | + }; | ||
| 40 | + for i in 0..archive.len() { | ||
| 41 | + let Ok(mut file) = archive.by_index(i) else { | ||
| 42 | + continue; | ||
| 43 | + }; | ||
| 44 | + let name = file.name().to_string(); | ||
| 45 | + if !name.ends_with(".png") { | ||
| 46 | + continue; | ||
| 47 | + } | ||
| 48 | + // Skip directory entries / paths with separators (zip-slip). | ||
| 49 | + if name.contains('/') || name.contains('\\') { | ||
| 50 | + continue; | ||
| 51 | + } | ||
| 52 | + let mut buf = Vec::new(); | ||
| 53 | + if file.read_to_end(&mut buf).is_ok() { | ||
| 54 | + map.insert(name, buf); | ||
| 55 | + } | ||
| 56 | + } | ||
| 57 | + map | ||
| 58 | + }) | ||
| 59 | +} | ||
| 60 | + | ||
| 61 | +/// Named shortcuts for common reactions + UI chrome. | ||
| 62 | +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] | ||
| 63 | +pub enum Icon { | ||
| 64 | + ThumbsUp, | ||
| 65 | + ThumbsDown, | ||
| 66 | + Heart, | ||
| 67 | + Laugh, | ||
| 68 | + Surprised, | ||
| 69 | + Frown, | ||
| 70 | + /// Drawn stroke — not a Twemoji glyph. | ||
| 71 | + Plus, | ||
| 72 | + /// Drawn stroke — overlapping pages (copy / duplicate). | ||
| 73 | + Copy, | ||
| 74 | +} | ||
| 75 | + | ||
| 76 | +impl Icon { | ||
| 77 | + pub const EMOJI: &'static [Icon] = &[ | ||
| 78 | + Icon::ThumbsUp, | ||
| 79 | + Icon::Heart, | ||
| 80 | + Icon::Laugh, | ||
| 81 | + Icon::Surprised, | ||
| 82 | + Icon::Frown, | ||
| 83 | + Icon::ThumbsDown, | ||
| 84 | + ]; | ||
| 85 | + | ||
| 86 | + pub const ALL: &'static [Icon] = &[ | ||
| 87 | + Icon::ThumbsUp, | ||
| 88 | + Icon::Heart, | ||
| 89 | + Icon::Laugh, | ||
| 90 | + Icon::Surprised, | ||
| 91 | + Icon::Frown, | ||
| 92 | + Icon::ThumbsDown, | ||
| 93 | + Icon::Plus, | ||
| 94 | + Icon::Copy, | ||
| 95 | + ]; | ||
| 96 | + | ||
| 97 | + /// True when this icon is stroke-drawn (not a Twemoji bitmap). | ||
| 98 | + pub fn is_stroke(self) -> bool { | ||
| 99 | + matches!(self, Icon::Plus | Icon::Copy) | ||
| 100 | + } | ||
| 101 | + | ||
| 102 | + pub fn emoji(self) -> &'static str { | ||
| 103 | + match self { | ||
| 104 | + Icon::ThumbsUp => "👍", | ||
| 105 | + Icon::ThumbsDown => "👎", | ||
| 106 | + Icon::Heart => "❤️", | ||
| 107 | + Icon::Laugh => "😂", | ||
| 108 | + Icon::Surprised => "😮", | ||
| 109 | + Icon::Frown => "😢", | ||
| 110 | + Icon::Plus => "+", | ||
| 111 | + Icon::Copy => "⧉", | ||
| 112 | + } | ||
| 113 | + } | ||
| 114 | +} | ||
| 115 | + | ||
| 116 | +/// Strip only pure presentation noise (kept for callers / labels). | ||
| 117 | +pub fn normalize_emoji(emoji: &str) -> String { | ||
| 118 | + emoji | ||
| 119 | + .chars() | ||
| 120 | + .filter(|&c| c != '\u{FE0E}' && c != '\u{FE0F}') | ||
| 121 | + .collect() | ||
| 122 | +} | ||
| 123 | + | ||
| 124 | +/// Map a well-known short emoji to [`Icon`] (convenience). Prefer [`emoji_icon`] | ||
| 125 | +/// for arbitrary reactions — that covers the full Twemoji set. | ||
| 126 | +pub fn icon_for_emoji(emoji: &str) -> Option<Icon> { | ||
| 127 | + let key: String = emoji | ||
| 128 | + .chars() | ||
| 129 | + .filter(|&c| { | ||
| 130 | + c != '\u{FE0E}' | ||
| 131 | + && c != '\u{FE0F}' | ||
| 132 | + && c != '\u{200D}' | ||
| 133 | + && !matches!(c, '\u{1F3FB}'..='\u{1F3FF}') | ||
| 134 | + }) | ||
| 135 | + .collect(); | ||
| 136 | + match key.as_str() { | ||
| 137 | + "👍" | "+1" => Some(Icon::ThumbsUp), | ||
| 138 | + "👎" | "-1" => Some(Icon::ThumbsDown), | ||
| 139 | + "❤" | "♥" => Some(Icon::Heart), | ||
| 140 | + "😂" | "😄" | "😆" | "🤣" => Some(Icon::Laugh), | ||
| 141 | + "😮" | "😯" | "😲" => Some(Icon::Surprised), | ||
| 142 | + "😢" | "😞" | "🙁" | "☹" | "😭" => Some(Icon::Frown), | ||
| 143 | + _ => None, | ||
| 144 | + } | ||
| 145 | +} | ||
| 146 | + | ||
| 147 | +/// True when we have a color Twemoji bitmap for this string. | ||
| 148 | +pub fn has_emoji_icon(emoji: &str) -> bool { | ||
| 149 | + twemoji_png(emoji).is_some() | ||
| 150 | +} | ||
| 151 | + | ||
| 152 | +/// How many glyphs are in the embedded pack (for demos / diagnostics). | ||
| 153 | +pub fn emoji_pack_len() -> usize { | ||
| 154 | + twemoji_pack().len() | ||
| 155 | +} | ||
| 156 | + | ||
| 157 | +// ── Twemoji codepoint keys (matches twemoji.js grabTheRightIcon) ──────────── | ||
| 158 | + | ||
| 159 | +const ZWJ: char = '\u{200D}'; | ||
| 160 | +const VS16: char = '\u{FE0F}'; | ||
| 161 | +const VS15: char = '\u{FE0E}'; | ||
| 162 | + | ||
| 163 | +/// Twemoji file stem: lowercase hex codepoints joined by `-`. | ||
| 164 | +/// | ||
| 165 | +/// Rule from twemoji: if the sequence has **no** ZWJ, strip FE0F/FE0E; | ||
| 166 | +/// if it has ZWJ, keep variation selectors (filenames include `fe0f`). | ||
| 167 | +pub fn twemoji_key(emoji: &str) -> String { | ||
| 168 | + let has_zwj = emoji.contains(ZWJ); | ||
| 169 | + let chars = emoji.chars().filter(|&c| { | ||
| 170 | + if has_zwj { | ||
| 171 | + true | ||
| 172 | + } else { | ||
| 173 | + c != VS16 && c != VS15 | ||
| 174 | + } | ||
| 175 | + }); | ||
| 176 | + chars | ||
| 177 | + .map(|c| format!("{:x}", c as u32)) | ||
| 178 | + .collect::<Vec<_>>() | ||
| 179 | + .join("-") | ||
| 180 | +} | ||
| 181 | + | ||
| 182 | +fn strip_skin_tones(emoji: &str) -> String { | ||
| 183 | + emoji | ||
| 184 | + .chars() | ||
| 185 | + .filter(|c| !matches!(c, '\u{1F3FB}'..='\u{1F3FF}')) | ||
| 186 | + .collect() | ||
| 187 | +} | ||
| 188 | + | ||
| 189 | +/// Candidate Twemoji stems, most specific first. | ||
| 190 | +fn twemoji_key_candidates(emoji: &str) -> Vec<String> { | ||
| 191 | + let mut out = Vec::new(); | ||
| 192 | + let mut push = |s: String| { | ||
| 193 | + if !s.is_empty() && !out.contains(&s) { | ||
| 194 | + out.push(s); | ||
| 195 | + } | ||
| 196 | + }; | ||
| 197 | + | ||
| 198 | + let trimmed = emoji.trim(); | ||
| 199 | + if trimmed.is_empty() { | ||
| 200 | + return out; | ||
| 201 | + } | ||
| 202 | + | ||
| 203 | + push(twemoji_key(trimmed)); | ||
| 204 | + | ||
| 205 | + // Always also try VS-stripped form (covers assets that omit FE0F). | ||
| 206 | + let no_vs: String = trimmed | ||
| 207 | + .chars() | ||
| 208 | + .filter(|&c| c != VS16 && c != VS15) | ||
| 209 | + .collect(); | ||
| 210 | + push(twemoji_key(&no_vs)); | ||
| 211 | + // Force “no ZWJ rule” key on the raw string by stripping VS. | ||
| 212 | + push( | ||
| 213 | + no_vs | ||
| 214 | + .chars() | ||
| 215 | + .map(|c| format!("{:x}", c as u32)) | ||
| 216 | + .collect::<Vec<_>>() | ||
| 217 | + .join("-"), | ||
| 218 | + ); | ||
| 219 | + | ||
| 220 | + // Skin-tone fallback → base glyph. | ||
| 221 | + let no_skin = strip_skin_tones(trimmed); | ||
| 222 | + if no_skin != trimmed { | ||
| 223 | + push(twemoji_key(&no_skin)); | ||
| 224 | + let no_skin_vs: String = no_skin | ||
| 225 | + .chars() | ||
| 226 | + .filter(|&c| c != VS16 && c != VS15) | ||
| 227 | + .collect(); | ||
| 228 | + push(twemoji_key(&no_skin_vs)); | ||
| 229 | + } | ||
| 230 | + | ||
| 231 | + // First extended grapheme-ish: take until second ZWJ-less “cluster” — for | ||
| 232 | + // multi-emoji paste, try the whole string then the first scalar sequence. | ||
| 233 | + // If still missing, try each scalar / ZWJ segment from the start. | ||
| 234 | + if trimmed.chars().count() > 1 { | ||
| 235 | + // Leading base only (first non-VS non-skin char + optional VS). | ||
| 236 | + if let Some(first) = trimmed.chars().find(|c| { | ||
| 237 | + *c != VS16 && *c != VS15 && !matches!(c, '\u{1F3FB}'..='\u{1F3FF}') && *c != ZWJ | ||
| 238 | + }) { | ||
| 239 | + push(format!("{:x}", first as u32)); | ||
| 240 | + } | ||
| 241 | + } | ||
| 242 | + | ||
| 243 | + out | ||
| 244 | +} | ||
| 245 | + | ||
| 246 | +/// PNG bytes for an emoji, if present in the pack. | ||
| 247 | +fn twemoji_png(emoji: &str) -> Option<&'static [u8]> { | ||
| 248 | + let pack = twemoji_pack(); | ||
| 249 | + for key in twemoji_key_candidates(emoji) { | ||
| 250 | + let name = format!("{key}.png"); | ||
| 251 | + if let Some(bytes) = pack.get(&name) { | ||
| 252 | + // Safe: pack is 'static, values live for process lifetime. | ||
| 253 | + return Some(bytes.as_slice()); | ||
| 254 | + } | ||
| 255 | + } | ||
| 256 | + None | ||
| 257 | +} | ||
| 258 | + | ||
| 259 | +// ── painting ──────────────────────────────────────────────────────────────── | ||
| 260 | + | ||
| 261 | +/// Paint a named icon at `size`×`size`. | ||
| 262 | +pub fn icon(ui: &mut Ui, theme: &Theme, icon: Icon, size: f32) -> Response { | ||
| 263 | + icon_colored(ui, theme.palette.text, icon, size) | ||
| 264 | +} | ||
| 265 | + | ||
| 266 | +/// Like [`icon`]; `color` only affects stroke icons ([`Icon::Plus`], [`Icon::Copy`]). | ||
| 267 | +pub fn icon_colored(ui: &mut Ui, color: Color32, icon: Icon, size: f32) -> Response { | ||
| 268 | + let size = size.max(8.0); | ||
| 269 | + match icon { | ||
| 270 | + Icon::Plus | Icon::Copy => paint_stroke_icon(ui, color, icon, size), | ||
| 271 | + other => { | ||
| 272 | + let (rect, response) = ui.allocate_exact_size(Vec2::splat(size), Sense::hover()); | ||
| 273 | + paint_emoji_in(ui, rect, other.emoji(), color); | ||
| 274 | + response | ||
| 275 | + } | ||
| 276 | + } | ||
| 277 | +} | ||
| 278 | + | ||
| 279 | +/// Paint any emoji as a color Twemoji when available; else text fallback. | ||
| 280 | +pub fn emoji_icon(ui: &mut Ui, theme: &Theme, emoji: &str, size: f32) -> Response { | ||
| 281 | + emoji_icon_colored(ui, theme, theme.palette.text, emoji, size) | ||
| 282 | +} | ||
| 283 | + | ||
| 284 | +/// Like [`emoji_icon`]; `color` is only for text / Plus fallback. | ||
| 285 | +pub fn emoji_icon_colored( | ||
| 286 | + ui: &mut Ui, | ||
| 287 | + theme: &Theme, | ||
| 288 | + color: Color32, | ||
| 289 | + emoji: &str, | ||
| 290 | + size: f32, | ||
| 291 | +) -> Response { | ||
| 292 | + let size = size.max(8.0); | ||
| 293 | + let (rect, response) = ui.allocate_exact_size(Vec2::splat(size), Sense::hover()); | ||
| 294 | + paint_emoji_in(ui, rect, emoji, color); | ||
| 295 | + let _ = theme; | ||
| 296 | + response | ||
| 297 | +} | ||
| 298 | + | ||
| 299 | +/// Draw a stroke icon ([`Icon::Plus`], [`Icon::Copy`]) into `rect`. | ||
| 300 | +/// Emoji icons need [`paint_emoji_in`] / [`paint_icon_in`]. | ||
| 301 | +pub fn paint_icon(painter: &egui::Painter, rect: Rect, icon: Icon, color: Color32) { | ||
| 302 | + let s = rect.width().min(rect.height()); | ||
| 303 | + let c = rect.center(); | ||
| 304 | + let w = (s * 0.12).clamp(1.5, 2.5); | ||
| 305 | + let stroke = Stroke::new(w, color); | ||
| 306 | + match icon { | ||
| 307 | + Icon::Plus => { | ||
| 308 | + let arm = s * 0.32; | ||
| 309 | + painter.line_segment([pos2(c.x - arm, c.y), pos2(c.x + arm, c.y)], stroke); | ||
| 310 | + painter.line_segment([pos2(c.x, c.y - arm), pos2(c.x, c.y + arm)], stroke); | ||
| 311 | + } | ||
| 312 | + Icon::Copy => { | ||
| 313 | + // Two overlapping pages — classic “copy” glyph (stroke only). | ||
| 314 | + let page_w = s * 0.38; | ||
| 315 | + let page_h = s * 0.46; | ||
| 316 | + let r = (s * 0.08).clamp(0.5, 2.5); | ||
| 317 | + let dx = s * 0.11; | ||
| 318 | + let dy = s * 0.11; | ||
| 319 | + let back = Rect::from_center_size(pos2(c.x - dx, c.y - dy), Vec2::new(page_w, page_h)); | ||
| 320 | + let front = Rect::from_center_size(pos2(c.x + dx, c.y + dy), Vec2::new(page_w, page_h)); | ||
| 321 | + painter.rect_stroke(back, r, stroke, egui::StrokeKind::Inside); | ||
| 322 | + painter.rect_stroke(front, r, stroke, egui::StrokeKind::Inside); | ||
| 323 | + } | ||
| 324 | + _ => {} | ||
| 325 | + } | ||
| 326 | +} | ||
| 327 | + | ||
| 328 | +/// Draw a named [`Icon`] into `rect`. | ||
| 329 | +pub fn paint_icon_in(ui: &Ui, rect: Rect, icon: Icon, color: Color32) { | ||
| 330 | + match icon { | ||
| 331 | + Icon::Plus | Icon::Copy => paint_icon(ui.painter(), rect, icon, color), | ||
| 332 | + other => paint_emoji_in(ui, rect, other.emoji(), color), | ||
| 333 | + } | ||
| 334 | +} | ||
| 335 | + | ||
| 336 | +/// Draw any emoji into `rect` (color Twemoji or text fallback). | ||
| 337 | +pub fn paint_emoji_in(ui: &Ui, rect: Rect, emoji: &str, color: Color32) { | ||
| 338 | + if !ui.is_rect_visible(rect) { | ||
| 339 | + return; | ||
| 340 | + } | ||
| 341 | + if let Some(tex) = load_emoji_texture(ui.ctx(), emoji) { | ||
| 342 | + Image::from_texture(SizedTexture::new(tex.id(), tex.size_vec2())) | ||
| 343 | + .fit_to_exact_size(rect.size()) | ||
| 344 | + .paint_at(ui, rect); | ||
| 345 | + return; | ||
| 346 | + } | ||
| 347 | + // Fallback: raw text (may be monochrome Noto / tofu). | ||
| 348 | + let shown = normalize_emoji(emoji); | ||
| 349 | + let size = rect.width().min(rect.height()); | ||
| 350 | + ui.painter().text( | ||
| 351 | + rect.center(), | ||
| 352 | + Align2::CENTER_CENTER, | ||
| 353 | + if shown.is_empty() { emoji } else { &shown }, | ||
| 354 | + FontId::proportional((size * 0.85).max(10.0)), | ||
| 355 | + color, | ||
| 356 | + ); | ||
| 357 | +} | ||
| 358 | + | ||
| 359 | +/// Themed reaction chip: color emoji + optional count, at the caption size. | ||
| 360 | +pub fn reaction_chip( | ||
| 361 | + ui: &mut Ui, | ||
| 362 | + theme: &Theme, | ||
| 363 | + emoji: &str, | ||
| 364 | + count: usize, | ||
| 365 | + mine: bool, | ||
| 366 | +) -> Response { | ||
| 367 | + let icon_size = (theme.type_scale.caption * 1.25).max(16.0); | ||
| 368 | + reaction_chip_sized(ui, theme, emoji, count, mine, icon_size) | ||
| 369 | +} | ||
| 370 | + | ||
| 371 | +/// Like [`reaction_chip`], with the glyph drawn at `icon_size` points. | ||
| 372 | +/// | ||
| 373 | +/// The pill around it is sized from the glyph rather than from the theme, so a | ||
| 374 | +/// small chip is small all through instead of a small picture adrift in a | ||
| 375 | +/// caption-sized pill. | ||
| 376 | +pub fn reaction_chip_sized( | ||
| 377 | + ui: &mut Ui, | ||
| 378 | + theme: &Theme, | ||
| 379 | + emoji: &str, | ||
| 380 | + count: usize, | ||
| 381 | + mine: bool, | ||
| 382 | + icon_size: f32, | ||
| 383 | +) -> Response { | ||
| 384 | + let p = &theme.palette; | ||
| 385 | + let icon_size = icon_size.max(8.0); | ||
| 386 | + let fill = if mine { | ||
| 387 | + p.accent.gamma_multiply(0.35) | ||
| 388 | + } else { | ||
| 389 | + p.headerbar_bg | ||
| 390 | + }; | ||
| 391 | + let border = if mine { | ||
| 392 | + Stroke::new(1.0_f32, p.accent.gamma_multiply(0.7)) | ||
| 393 | + } else { | ||
| 394 | + Stroke::new(1.0_f32, p.border_soft) | ||
| 395 | + }; | ||
| 396 | + let inner = egui::Frame::new() | ||
| 397 | + .fill(fill) | ||
| 398 | + .stroke(border) | ||
| 399 | + .corner_radius(icon_size * 0.75) | ||
| 400 | + .inner_margin(egui::Margin::symmetric( | ||
| 401 | + (icon_size * 0.5) as i8, | ||
| 402 | + (icon_size * 0.2) as i8, | ||
| 403 | + )) | ||
| 404 | + .show(ui, |ui| { | ||
| 405 | + ui.horizontal(|ui| { | ||
| 406 | + // Spacing is allocated by hand below, so that a chip showing a | ||
| 407 | + // number and one that is not can be laid out to the same width | ||
| 408 | + // without the gap between items being counted a different | ||
| 409 | + // number of times in each. | ||
| 410 | + ui.spacing_mut().item_spacing.x = 0.0; | ||
| 411 | + let text_size = theme.type_scale.caption.min(icon_size); | ||
| 412 | + let gap = (icon_size * 0.25).max(2.0); | ||
| 413 | + // Two digits, which is as far as a reaction count usually | ||
| 414 | + // goes, so one, nine and ninety-nine are all the same chip: a | ||
| 415 | + // chip that grew when a second person arrived would shuffle | ||
| 416 | + // every chip beside it along the row, and a row of reactions is | ||
| 417 | + // something people aim at. | ||
| 418 | + let slot = text_size * 1.2; | ||
| 419 | + let space = |ui: &mut Ui, w: f32| { | ||
| 420 | + if w > 0.0 { | ||
| 421 | + ui.allocate_exact_size(Vec2::new(w, icon_size), Sense::hover()); | ||
| 422 | + } | ||
| 423 | + }; | ||
| 424 | + match count { | ||
| 425 | + // Not a reaction but the offer of one — what a picker is | ||
| 426 | + // made of. Nothing to count, so nothing is set aside for a | ||
| 427 | + // count and the glyph has the pill to itself. | ||
| 428 | + 0 => { | ||
| 429 | + emoji_icon(ui, theme, emoji, icon_size); | ||
| 430 | + } | ||
| 431 | + // One reactor, and the "1" goes without saying. The room it | ||
| 432 | + // would have taken is split either side of the glyph rather | ||
| 433 | + // than left hanging off the end: the chip is the width of | ||
| 434 | + // one that does show a number, and still centred. | ||
| 435 | + 1 => { | ||
| 436 | + space(ui, (gap + slot) / 2.0); | ||
| 437 | + emoji_icon(ui, theme, emoji, icon_size); | ||
| 438 | + space(ui, (gap + slot) / 2.0); | ||
| 439 | + } | ||
| 440 | + n => { | ||
| 441 | + emoji_icon(ui, theme, emoji, icon_size); | ||
| 442 | + space(ui, gap); | ||
| 443 | + let (rect, _) = | ||
| 444 | + ui.allocate_exact_size(Vec2::new(slot, icon_size), Sense::hover()); | ||
| 445 | + ui.painter().text( | ||
| 446 | + rect.center(), | ||
| 447 | + Align2::CENTER_CENTER, | ||
| 448 | + n.to_string(), | ||
| 449 | + FontId::proportional(text_size), | ||
| 450 | + p.text, | ||
| 451 | + ); | ||
| 452 | + } | ||
| 453 | + } | ||
| 454 | + }); | ||
| 455 | + }); | ||
| 456 | + // A frame's own response only senses hover, so a chip built from one is | ||
| 457 | + // unclickable however it looks — and a reaction chip is a button: clicking | ||
| 458 | + // it is how a reaction is put on or taken off. `interact` is what gives the | ||
| 459 | + // rect the frame occupies a click to report. | ||
| 460 | + let response = inner.response.interact(Sense::click()); | ||
| 461 | + if response.hovered() { | ||
| 462 | + ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); | ||
| 463 | + } | ||
| 464 | + response | ||
| 465 | +} | ||
| 466 | + | ||
| 467 | +// ── texture cache ─────────────────────────────────────────────────────────── | ||
| 468 | + | ||
| 469 | +fn load_emoji_texture(ctx: &egui::Context, emoji: &str) -> Option<TextureHandle> { | ||
| 470 | + let key = twemoji_key_candidates(emoji).into_iter().next()?; | ||
| 471 | + // Cache by resolved pack key so ❤️ and ❤ share a texture when they hit the | ||
| 472 | + // same file; we re-resolve via png bytes identity below. | ||
| 473 | + let png = twemoji_png(emoji)?; | ||
| 474 | + // Stable id from the actual file we loaded (content address via key candidates). | ||
| 475 | + let file_key = twemoji_key_candidates(emoji) | ||
| 476 | + .into_iter() | ||
| 477 | + .find(|k| twemoji_pack().contains_key(&format!("{k}.png"))) | ||
| 478 | + .unwrap_or(key); | ||
| 479 | + let cache_id = Id::new(("vidya/twemoji", file_key.as_str())); | ||
| 480 | + | ||
| 481 | + if let Some(tex) = ctx.data(|d| d.get_temp::<TextureHandle>(cache_id)) { | ||
| 482 | + return Some(tex); | ||
| 483 | + } | ||
| 484 | + | ||
| 485 | + let image = decode_png_rgba(png)?; | ||
| 486 | + let handle = ctx.load_texture( | ||
| 487 | + format!("vidya/twemoji/{file_key}"), | ||
| 488 | + image, | ||
| 489 | + TextureOptions::LINEAR, | ||
| 490 | + ); | ||
| 491 | + ctx.data_mut(|d| d.insert_temp(cache_id, handle.clone())); | ||
| 492 | + Some(handle) | ||
| 493 | +} | ||
| 494 | + | ||
| 495 | +fn decode_png_rgba(bytes: &[u8]) -> Option<ColorImage> { | ||
| 496 | + let mut decoder = png::Decoder::new(Cursor::new(bytes)); | ||
| 497 | + decoder.set_transformations(png::Transformations::EXPAND | png::Transformations::ALPHA); | ||
| 498 | + let mut reader = decoder.read_info().ok()?; | ||
| 499 | + let mut buf = vec![0; reader.output_buffer_size()]; | ||
| 500 | + let info = reader.next_frame(&mut buf).ok()?; | ||
| 501 | + let w = info.width as usize; | ||
| 502 | + let h = info.height as usize; | ||
| 503 | + let raw = &buf[..info.buffer_size()]; | ||
| 504 | + let rgba: Vec<u8> = match info.color_type { | ||
| 505 | + png::ColorType::Rgba => raw.to_vec(), | ||
| 506 | + png::ColorType::Rgb => { | ||
| 507 | + let mut out = Vec::with_capacity(w * h * 4); | ||
| 508 | + for chunk in raw.chunks_exact(3) { | ||
| 509 | + out.extend_from_slice(&[chunk[0], chunk[1], chunk[2], 255]); | ||
| 510 | + } | ||
| 511 | + out | ||
| 512 | + } | ||
| 513 | + _ => return None, | ||
| 514 | + }; | ||
| 515 | + if rgba.len() != w * h * 4 { | ||
| 516 | + return None; | ||
| 517 | + } | ||
| 518 | + Some(ColorImage::from_rgba_unmultiplied([w, h], &rgba)) | ||
| 519 | +} | ||
| 520 | + | ||
| 521 | +fn paint_stroke_icon(ui: &mut Ui, color: Color32, icon: Icon, size: f32) -> Response { | ||
| 522 | + let (rect, response) = ui.allocate_exact_size(Vec2::splat(size), Sense::hover()); | ||
| 523 | + if ui.is_rect_visible(rect) { | ||
| 524 | + paint_icon(ui.painter(), rect, icon, color); | ||
| 525 | + } | ||
| 526 | + response | ||
| 527 | +} | ||
| 528 | + | ||
| 529 | +#[cfg(test)] | ||
| 530 | +mod tests { | ||
| 531 | + use super::*; | ||
| 532 | + | ||
| 533 | + #[test] | ||
| 534 | + fn pack_is_populated() { | ||
| 535 | + let n = emoji_pack_len(); | ||
| 536 | + assert!(n > 3000, "expected full Twemoji set, got {n}"); | ||
| 537 | + } | ||
| 538 | + | ||
| 539 | + #[test] | ||
| 540 | + fn keys_match_twemoji_filenames() { | ||
| 541 | + assert_eq!(twemoji_key("👍"), "1f44d"); | ||
| 542 | + assert_eq!(twemoji_key("❤️"), "2764"); // VS16 stripped (no ZWJ) | ||
| 543 | + assert_eq!(twemoji_key("😂"), "1f602"); | ||
| 544 | + assert_eq!(twemoji_key("🚀"), "1f680"); | ||
| 545 | + // ZWJ sequence keeps fe0f when present in input | ||
| 546 | + let technologist = "👨\u{200D}💻"; | ||
| 547 | + assert_eq!(twemoji_key(technologist), "1f468-200d-1f4bb"); | ||
| 548 | + } | ||
| 549 | + | ||
| 550 | + /// A chip nobody can click is a picture of a button. The frame it is built | ||
| 551 | + /// from only senses hover on its own, so this is the regression that | ||
| 552 | + /// matters: reacting is a click on this widget. | ||
| 553 | + #[test] | ||
| 554 | + fn chip_senses_clicks() { | ||
| 555 | + let theme = Theme::dark(); | ||
| 556 | + let ctx = egui::Context::default(); | ||
| 557 | + let mut sense = Sense::hover(); | ||
| 558 | + let _ = ctx.run(Default::default(), |ctx| { | ||
| 559 | + egui::CentralPanel::default().show(ctx, |ui| { | ||
| 560 | + sense = reaction_chip(ui, &theme, "\u{1f44d}", 2, false).sense; | ||
| 561 | + }); | ||
| 562 | + }); | ||
| 563 | + assert!(sense.senses_click(), "reaction chip does not sense clicks"); | ||
| 564 | + } | ||
| 565 | + | ||
| 566 | + #[test] | ||
| 567 | + fn resolves_common_and_rare() { | ||
| 568 | + for e in [ | ||
| 569 | + "👍", "❤️", "❤", "😂", "😮", "😢", "👎", "🚀", "🎉", "🔥", "✨", "👀", "💯", "🙏", | ||
| 570 | + "😎", "🤯", "🏳️", "🏴", | ||
| 571 | + ] { | ||
| 572 | + assert!( | ||
| 573 | + has_emoji_icon(e), | ||
| 574 | + "missing Twemoji for {e:?} key={:?}", | ||
| 575 | + twemoji_key_candidates(e) | ||
| 576 | + ); | ||
| 577 | + } | ||
| 578 | + } | ||
| 579 | + | ||
| 580 | + #[test] | ||
| 581 | + fn skin_tone_falls_back_to_base() { | ||
| 582 | + // 👍🏻 → base 👍 asset | ||
| 583 | + assert!(has_emoji_icon("👍🏻")); | ||
| 584 | + } | ||
| 585 | + | ||
| 586 | + #[test] | ||
| 587 | + fn zwj_sequence() { | ||
| 588 | + assert!(has_emoji_icon("👨💻"), "technologist ZWJ"); | ||
| 589 | + } | ||
| 590 | + | ||
| 591 | + #[test] | ||
| 592 | + fn unknown_text_has_no_icon() { | ||
| 593 | + assert!(!has_emoji_icon("hello")); | ||
| 594 | + assert!(!has_emoji_icon("")); | ||
| 595 | + } | ||
| 596 | + | ||
| 597 | + #[test] | ||
| 598 | + fn icon_shortcuts() { | ||
| 599 | + assert_eq!(icon_for_emoji("👍"), Some(Icon::ThumbsUp)); | ||
| 600 | + assert_eq!(icon_for_emoji("❤️"), Some(Icon::Heart)); | ||
| 601 | + } | ||
| 602 | + | ||
| 603 | + #[test] | ||
| 604 | + fn pngs_decode() { | ||
| 605 | + for e in ["👍", "❤️", "😂", "🚀", "👨💻"] { | ||
| 606 | + let png = twemoji_png(e).unwrap_or_else(|| panic!("no png for {e}")); | ||
| 607 | + let img = decode_png_rgba(png).unwrap_or_else(|| panic!("decode {e}")); | ||
| 608 | + assert_eq!(img.size, [72, 72]); | ||
| 609 | + } | ||
| 610 | + } | ||
| 611 | +} | ||
added
crates/vidya-core/src/layout.rs +1149 -0 | new file mode 100644 | ||
| @@ -0,0 +1,1149 @@ | ||
| 1 | +//! Layout composition surface — width-safe, compact-by-default primitives. | |
| 2 | +//! | |
| 3 | +//! Apps should prefer these helpers over raw `set_max_width` / `Layout::…` | |
| 4 | +//! plumbing. Escape hatches stay (egui is still available); this module makes | |
| 5 | +//! the **good defaults** the short path. | |
| 6 | +//! | |
| 7 | +//! # Patterns → primitives | |
| 8 | +//! | |
| 9 | +//! | App footgun | Primitive | | |
| 10 | +//! |-------------|-----------| | |
| 11 | +//! | Page scrolls off / under edge | [`page_body`] / [`central_page`] | | |
| 12 | +//! | Free-stack app UI at top level | **enforced:** [`page_body`] / [`central_page`] take a grid DSL | | |
| 13 | +//! | Card overflows column | [`card`] / [`compact_card`] | | |
| 14 | +//! | Giant gaps when parent is tall | [`vstack`] (non-justified) | | |
| 15 | +//! | Fixed tiles stretch across the window | [`pack`] (wrap, hug content) | | |
| 16 | +//! | Actions clipped off the right | [`lead_trail`] | | |
| 17 | +//! | Side-by-side vs stack breakpoint | [`two_col`] / [`side_by_side`] | | |
| 18 | +//! | Rate columns staircase ("waterfall") | [`metric_bps`] / [`metric_rate`] / [`metric_cell`] / [`grid_cols`] / [`data_table`] | | |
| 19 | +//! | |
| 20 | +//! # Top-level application UI (enforced) | |
| 21 | +//! | |
| 22 | +//! Application content in the central panel **must** be composed through the | |
| 23 | +//! grid DSL. [`page_body`] and [`central_page`] only accept a [`GridCtx`] | |
| 24 | +//! callback — free stacking of widgets at the page root is not part of the | |
| 25 | +//! public path. Nested cards, nested [`grid_cols`], gauges, and text go | |
| 26 | +//! **inside** cells / [`GridCtx::section`]s. | |
| 27 | +//! | |
| 28 | +//! ```ignore | |
| 29 | +//! vidya::central_page(ctx, &th, "main", |g| { | |
| 30 | +//! g.section(|ui| { /* gauges row — often a nested grid_cols */ }); | |
| 31 | +//! g.section(|ui| { /* process table */ }); | |
| 32 | +//! }); | |
| 33 | +//! ``` | |
| 34 | +//! | |
| 35 | +//! Escape hatch (scroll + width pin only, no grid): [`page_scroll`]. | |
| 36 | + | |
| 37 | +use std::hash::Hash; | |
| 38 | + | |
| 39 | +use egui::{ | |
| 40 | + Align, FontId, Frame, Grid, InnerResponse, Layout, Margin, RichText, ScrollArea, Sense, Stroke, | |
| 41 | + Ui, Vec2, | |
| 42 | +}; | |
| 43 | + | |
| 44 | +use crate::Theme; | |
| 45 | + | |
| 46 | +// ── Pure policy (unit-tested without a window) ────────────────────────────── | |
| 47 | + | |
| 48 | +/// Minimum residual width (px) before two equal columns fit with `gap`. | |
| 49 | +/// | |
| 50 | +/// `true` means place side-by-side; `false` means stack vertically. | |
| 51 | +pub fn side_by_side(avail: f32, min_col: f32, gap: f32) -> bool { | |
| 52 | + avail >= min_col * 2.0 + gap && min_col > 0.0 && avail > 0.0 | |
| 53 | +} | |
| 54 | + | |
| 55 | +/// Default min column width for [`two_col`] when apps pass theme spacing. | |
| 56 | +pub fn default_min_col(theme: &Theme) -> f32 { | |
| 57 | + // Roughly one compact card: control + padding. | |
| 58 | + theme.spacing.control_height * 6.0 + theme.spacing.page | |
| 59 | +} | |
| 60 | + | |
| 61 | +/// Character width of a fixed monospace throughput cell ([`metric_bps`]). | |
| 62 | +pub const METRIC_BPS_CHARS: usize = 14; | |
| 63 | + | |
| 64 | +/// Character width of a fixed monospace event-rate cell ([`metric_rate`]). | |
| 65 | +pub const METRIC_RATE_CHARS: usize = 10; | |
| 66 | + | |
| 67 | +/// Minimum pixel width for a metric cell of `chars` monospace glyphs + padding. | |
| 68 | +/// | |
| 69 | +/// Used as a **floor** for [`ColSpec::MetricBps`] / [`MetricRate`]. Actual cells | |
| 70 | +/// still grow via [`metric_cell`] if the painted string is wider. | |
| 71 | +pub fn metric_cell_px(theme: &Theme, chars: usize) -> f32 { | |
| 72 | + // Monospace advance is typically ~0.6–0.65em; use 0.72 so columns never | |
| 73 | + // undershoot common caption sizes (was 0.62 and clipped padded metrics). | |
| 74 | + let advance = theme.type_scale.caption * 0.72; | |
| 75 | + let pad = theme.spacing.md + theme.spacing.sm; | |
| 76 | + advance * chars as f32 + pad | |
| 77 | +} | |
| 78 | + | |
| 79 | +/// Left-pad `s` to exactly `width` characters (Unicode scalar count). | |
| 80 | +/// | |
| 81 | +/// Longer strings are returned unchanged (width is a minimum for alignment). | |
| 82 | +pub fn pad_metric(s: &str, width: usize) -> String { | |
| 83 | + let n = s.chars().count(); | |
| 84 | + if n >= width { | |
| 85 | + s.to_string() | |
| 86 | + } else { | |
| 87 | + format!("{:>width$}", s, width = width) | |
| 88 | + } | |
| 89 | +} | |
| 90 | + | |
| 91 | +/// Human-readable throughput (B/s → KiB/s, …) without padding. | |
| 92 | +pub fn format_bps(bps: f64) -> String { | |
| 93 | + const UNITS: [&str; 5] = ["B/s", "KiB/s", "MiB/s", "GiB/s", "TiB/s"]; | |
| 94 | + let mut v = bps.max(0.0); | |
| 95 | + let mut i = 0; | |
| 96 | + while v >= 1024.0 && i < UNITS.len() - 1 { | |
| 97 | + v /= 1024.0; | |
| 98 | + i += 1; | |
| 99 | + } | |
| 100 | + if i == 0 { | |
| 101 | + format!("{v:.0} {}", UNITS[i]) | |
| 102 | + } else if v >= 100.0 { | |
| 103 | + format!("{v:.0} {}", UNITS[i]) | |
| 104 | + } else if v >= 10.0 { | |
| 105 | + format!("{v:.1} {}", UNITS[i]) | |
| 106 | + } else { | |
| 107 | + format!("{v:.2} {}", UNITS[i]) | |
| 108 | + } | |
| 109 | +} | |
| 110 | + | |
| 111 | +/// Fixed-width throughput for tables (monospace-safe; both edges flush). | |
| 112 | +pub fn metric_bps(bps: f64) -> String { | |
| 113 | + pad_metric(&format_bps(bps), METRIC_BPS_CHARS) | |
| 114 | +} | |
| 115 | + | |
| 116 | +/// Human-readable event rate (e.g. write syscalls/s) without padding. | |
| 117 | +pub fn format_rate(rate: f64) -> String { | |
| 118 | + if rate < 0.05 { | |
| 119 | + "0/s".into() | |
| 120 | + } else if rate < 10.0 { | |
| 121 | + format!("{rate:.1}/s") | |
| 122 | + } else if rate < 1000.0 { | |
| 123 | + format!("{rate:.0}/s") | |
| 124 | + } else if rate < 1_000_000.0 { | |
| 125 | + format!("{:.1}k/s", rate / 1000.0) | |
| 126 | + } else { | |
| 127 | + format!("{:.1}M/s", rate / 1_000_000.0) | |
| 128 | + } | |
| 129 | +} | |
| 130 | + | |
| 131 | +/// Fixed-width event rate for tables. | |
| 132 | +pub fn metric_rate(rate: f64) -> String { | |
| 133 | + pad_metric(&format_rate(rate), METRIC_RATE_CHARS) | |
| 134 | +} | |
| 135 | + | |
| 136 | +// ── Width-safe scopes ─────────────────────────────────────────────────────── | |
| 137 | + | |
| 138 | +/// Pin this scope so children cannot expand past the current available width. | |
| 139 | +pub fn fit_width(ui: &mut Ui, add: impl FnOnce(&mut Ui)) -> InnerResponse<()> { | |
| 140 | + let w = ui.available_width().max(1.0); | |
| 141 | + ui.scope(|ui| { | |
| 142 | + ui.set_max_width(w); | |
| 143 | + add(ui); | |
| 144 | + }) | |
| 145 | +} | |
| 146 | + | |
| 147 | +/// Same as [`fit_width`], but also set `min_width` so framed children fill the residual. | |
| 148 | +pub fn fill_width(ui: &mut Ui, add: impl FnOnce(&mut Ui)) -> InnerResponse<()> { | |
| 149 | + let w = ui.available_width().max(1.0); | |
| 150 | + ui.scope(|ui| { | |
| 151 | + ui.set_min_width(w); | |
| 152 | + ui.set_max_width(w); | |
| 153 | + add(ui); | |
| 154 | + }) | |
| 155 | +} | |
| 156 | + | |
| 157 | +/// Non-justified vertical stack — **no giant gaps** when the parent is taller | |
| 158 | +/// than the content (the usual egui “waterfall” inside stretched cards). | |
| 159 | +pub fn vstack(ui: &mut Ui, theme: &Theme, add: impl FnOnce(&mut Ui)) -> InnerResponse<()> { | |
| 160 | + let gap = theme.spacing.sm; | |
| 161 | + ui.with_layout(Layout::top_down(Align::Min), |ui| { | |
| 162 | + ui.spacing_mut().item_spacing = Vec2::new(ui.spacing().item_spacing.x, gap); | |
| 163 | + add(ui); | |
| 164 | + }) | |
| 165 | +} | |
| 166 | + | |
| 167 | +// ── Surfaces ──────────────────────────────────────────────────────────────── | |
| 168 | + | |
| 169 | +/// Themed card that **fills** the parent column without overflowing past it. | |
| 170 | +/// | |
| 171 | +/// Content is stacked with [`vstack`] so tall parents do not justify gaps. | |
| 172 | +pub fn card(ui: &mut Ui, theme: &Theme, add: impl FnOnce(&mut Ui)) -> InnerResponse<()> { | |
| 173 | + let outer = ui.available_width().max(1.0); | |
| 174 | + ui.set_max_width(outer); | |
| 175 | + theme.card_frame().show(ui, |ui| { | |
| 176 | + let inner = ui.available_width().max(1.0); | |
| 177 | + ui.set_min_width(inner); | |
| 178 | + ui.set_max_width(inner); | |
| 179 | + vstack(ui, theme, add); | |
| 180 | + }) | |
| 181 | +} | |
| 182 | + | |
| 183 | +/// Horizontal chrome (inner margin + stroke) added by [`Theme::card_frame`]. | |
| 184 | +/// | |
| 185 | +/// Outer card width = content width + this value. Pure / testable. | |
| 186 | +pub fn card_frame_chrome_x(theme: &Theme) -> f32 { | |
| 187 | + // card_frame: Margin::same(spacing.md) + Stroke width 1 on each side. | |
| 188 | + theme.spacing.md * 2.0 + 2.0 | |
| 189 | +} | |
| 190 | + | |
| 191 | +/// Compact card with a **fixed outer width** that hugs content height. | |
| 192 | +/// | |
| 193 | +/// Use for gauge tiles and anomaly panels that must not stretch across the | |
| 194 | +/// window or absorb leftover horizontal space between siblings. | |
| 195 | +/// | |
| 196 | +/// Outer size is clamped to residual width; content width subtracts frame | |
| 197 | +/// chrome so the painted card never exceeds the budget (avoids grid overflow). | |
| 198 | +pub fn compact_card( | |
| 199 | + ui: &mut Ui, | |
| 200 | + theme: &Theme, | |
| 201 | + width: f32, | |
| 202 | + add: impl FnOnce(&mut Ui), | |
| 203 | +) -> InnerResponse<()> { | |
| 204 | + let chrome = card_frame_chrome_x(theme); | |
| 205 | + // Outer size must fit the residual (grid cell / viewport). | |
| 206 | + let outer = width.min(ui.available_width()).max(1.0); | |
| 207 | + let inner = (outer - chrome).max(1.0); | |
| 208 | + | |
| 209 | + ui.allocate_ui_with_layout(Vec2::new(outer, 0.0), Layout::top_down(Align::Min), |ui| { | |
| 210 | + ui.set_min_width(outer); | |
| 211 | + ui.set_max_width(outer); | |
| 212 | + // Do NOT set_clip_rect here: max_rect starts with height 0 before | |
| 213 | + // children run, which would hide all content (blank window). | |
| 214 | + theme.card_frame().show(ui, |ui| { | |
| 215 | + ui.set_min_width(inner); | |
| 216 | + ui.set_max_width(inner); | |
| 217 | + vstack(ui, theme, add); | |
| 218 | + }); | |
| 219 | + }) | |
| 220 | +} | |
| 221 | + | |
| 222 | +/// Soft-bordered inset row (popover surface) capped to parent width. | |
| 223 | +pub fn inset_row(ui: &mut Ui, theme: &Theme, add: impl FnOnce(&mut Ui)) -> InnerResponse<()> { | |
| 224 | + let outer = ui.available_width().max(1.0); | |
| 225 | + ui.set_max_width(outer); | |
| 226 | + Frame::new() | |
| 227 | + .fill(theme.palette.popover_bg) | |
| 228 | + .stroke(Stroke::new(1.0, theme.palette.border_soft)) | |
| 229 | + .corner_radius(theme.spacing.radius_sm) | |
| 230 | + .inner_margin(Margin::symmetric( | |
| 231 | + theme.spacing.md as i8, | |
| 232 | + theme.spacing.sm as i8, | |
| 233 | + )) | |
| 234 | + .show(ui, |ui| { | |
| 235 | + let w = ui.available_width().max(1.0); | |
| 236 | + ui.set_min_width(w); | |
| 237 | + ui.set_max_width(w); | |
| 238 | + vstack(ui, theme, add); | |
| 239 | + }) | |
| 240 | +} | |
| 241 | + | |
| 242 | +// ── Horizontal composition ────────────────────────────────────────────────── | |
| 243 | + | |
| 244 | +/// Horizontal flow that **wraps** before clipping the edge (toolbars / chips). | |
| 245 | +pub fn hflow(ui: &mut Ui, theme: &Theme, add: impl FnOnce(&mut Ui)) -> InnerResponse<()> { | |
| 246 | + let w = ui.available_width().max(1.0); | |
| 247 | + let gap = theme.spacing.sm; | |
| 248 | + ui.scope(|ui| { | |
| 249 | + ui.set_max_width(w); | |
| 250 | + ui.spacing_mut().item_spacing = Vec2::new(gap, gap); | |
| 251 | + ui.horizontal_wrapped(add); | |
| 252 | + }) | |
| 253 | +} | |
| 254 | + | |
| 255 | +/// Pack of **compact** children (fixed-size cards/tiles) that wrap without | |
| 256 | +/// stretching leftover horizontal space into empty gaps between items. | |
| 257 | +/// | |
| 258 | +/// Same wrapping as [`hflow`], but spacing defaults to `md` so packs match | |
| 259 | +/// gauge / anomaly card groups. | |
| 260 | +pub fn pack(ui: &mut Ui, theme: &Theme, add: impl FnOnce(&mut Ui)) -> InnerResponse<()> { | |
| 261 | + let w = ui.available_width().max(1.0); | |
| 262 | + let gap = theme.spacing.md; | |
| 263 | + ui.scope(|ui| { | |
| 264 | + ui.set_max_width(w); | |
| 265 | + ui.spacing_mut().item_spacing = Vec2::new(gap, gap); | |
| 266 | + ui.horizontal_wrapped(add); | |
| 267 | + }) | |
| 268 | +} | |
| 269 | + | |
| 270 | +/// Leading content grows into remaining width; trailing actions stay visible. | |
| 271 | +/// | |
| 272 | +/// Height hugs content. Do **not** use bare `with_layout(… Align::Center)` here: | |
| 273 | +/// egui expands centered horizontal frames to the parent's full available | |
| 274 | +/// height, so `min_rect` eats the panel and everything below (toolbars, | |
| 275 | +/// tables, scroll areas) gets zero height — a blank detail pane. | |
| 276 | +pub fn lead_trail( | |
| 277 | + ui: &mut Ui, | |
| 278 | + leading: impl FnOnce(&mut Ui), | |
| 279 | + trailing: impl FnOnce(&mut Ui), | |
| 280 | +) -> InnerResponse<()> { | |
| 281 | + let w = ui.available_width().max(1.0); | |
| 282 | + ui.allocate_ui_with_layout( | |
| 283 | + Vec2::new(w, 0.0), | |
| 284 | + Layout::right_to_left(Align::Center), | |
| 285 | + |ui| { | |
| 286 | + trailing(ui); | |
| 287 | + let rest = ui.available_width().max(1.0); | |
| 288 | + ui.allocate_ui_with_layout( | |
| 289 | + Vec2::new(rest, 0.0), | |
| 290 | + Layout::left_to_right(Align::Center), | |
| 291 | + |ui| { | |
| 292 | + ui.set_max_width(rest); | |
| 293 | + ui.set_min_width(rest); | |
| 294 | + leading(ui); | |
| 295 | + }, | |
| 296 | + ); | |
| 297 | + }, | |
| 298 | + ) | |
| 299 | +} | |
| 300 | + | |
| 301 | +/// Two columns when [`side_by_side`] says so; otherwise stack. | |
| 302 | +pub fn two_col( | |
| 303 | + ui: &mut Ui, | |
| 304 | + theme: &Theme, | |
| 305 | + min_col: f32, | |
| 306 | + left: impl FnOnce(&mut Ui), | |
| 307 | + right: impl FnOnce(&mut Ui), | |
| 308 | +) { | |
| 309 | + let gap = theme.spacing.md; | |
| 310 | + let avail = ui.available_width(); | |
| 311 | + if side_by_side(avail, min_col, gap) { | |
| 312 | + ui.columns(2, |cols| { | |
| 313 | + cols[0].set_width(cols[0].available_width()); | |
| 314 | + left(&mut cols[0]); | |
| 315 | + cols[1].set_width(cols[1].available_width()); | |
| 316 | + right(&mut cols[1]); | |
| 317 | + }); | |
| 318 | + } else { | |
| 319 | + left(ui); | |
| 320 | + ui.add_space(gap); | |
| 321 | + right(ui); | |
| 322 | + } | |
| 323 | +} | |
| 324 | + | |
| 325 | +// ── Page shell ────────────────────────────────────────────────────────────── | |
| 326 | +// | |
| 327 | +// Application UI at the top level is **grid-only**. `page_body` / | |
| 328 | +// `central_page` take a `GridCtx` callback so free-form stacking cannot be | |
| 329 | +// the supported root composition path. Nested content lives inside cells. | |
| 330 | + | |
| 331 | +/// Escape hatch: pin width + vertical scroll **without** a top-level grid. | |
| 332 | +/// | |
| 333 | +/// Prefer [`page_body`] / [`central_page`] for application UI. Use this only | |
| 334 | +/// when a demo or special chrome needs free-form scrolling content. | |
| 335 | +pub fn page_scroll(ui: &mut Ui, add: impl FnOnce(&mut Ui)) { | |
| 336 | + let w = ui.available_width().max(1.0); | |
| 337 | + ui.set_max_width(w); | |
| 338 | + ScrollArea::vertical() | |
| 339 | + .auto_shrink([false, false]) | |
| 340 | + .id_salt("vidya_page_scroll") | |
| 341 | + .show(ui, |ui| { | |
| 342 | + let inner = ui.available_width().max(1.0); | |
| 343 | + ui.set_max_width(inner); | |
| 344 | + add(ui); | |
| 345 | + }); | |
| 346 | +} | |
| 347 | + | |
| 348 | +/// Scrollable application page whose **top-level content is a grid**. | |
| 349 | +/// | |
| 350 | +/// Defaults to a single flex column: each [`GridCtx::section`] (or `g.row`) | |
| 351 | +/// is a full-width page block. Nested multi-column layouts use [`grid_cols`] | |
| 352 | +/// inside a section/cell. | |
| 353 | +/// | |
| 354 | +/// This is the supported root for app UI — the callback cannot receive a raw | |
| 355 | +/// free-stack `Ui` at the page root. | |
| 356 | +pub fn page_body( | |
| 357 | + ui: &mut Ui, | |
| 358 | + theme: &Theme, | |
| 359 | + id: impl Hash, | |
| 360 | + add: impl FnOnce(&mut GridCtx<'_, '_>), | |
| 361 | +) { | |
| 362 | + page_body_cols(ui, theme, id, &[ColSpec::Flex], add); | |
| 363 | +} | |
| 364 | + | |
| 365 | +/// Like [`page_body`], but with explicit top-level column specs. | |
| 366 | +pub fn page_body_cols( | |
| 367 | + ui: &mut Ui, | |
| 368 | + theme: &Theme, | |
| 369 | + id: impl Hash, | |
| 370 | + cols: &[ColSpec], | |
| 371 | + add: impl FnOnce(&mut GridCtx<'_, '_>), | |
| 372 | +) { | |
| 373 | + page_scroll(ui, |ui| { | |
| 374 | + // Page grid: no zebra striping, larger vertical gap between sections. | |
| 375 | + grid_cols_with(ui, theme, id, cols, GridOpts::page(theme), add); | |
| 376 | + }); | |
| 377 | +} | |
| 378 | + | |
| 379 | +/// Full central page: themed page frame + grid-enforced [`page_body`]. | |
| 380 | +/// | |
| 381 | +/// Application central content **must** be composed via the grid DSL | |
| 382 | +/// (`g.section` / `g.row`). See module docs. | |
| 383 | +pub fn central_page( | |
| 384 | + ctx: &egui::Context, | |
| 385 | + theme: &Theme, | |
| 386 | + id: impl Hash, | |
| 387 | + add: impl FnOnce(&mut GridCtx<'_, '_>), | |
| 388 | +) -> egui::InnerResponse<()> { | |
| 389 | + egui::CentralPanel::default() | |
| 390 | + .frame(theme.page_frame()) | |
| 391 | + .show(ctx, |ui| { | |
| 392 | + page_body(ui, theme, id, add); | |
| 393 | + }) | |
| 394 | +} | |
| 395 | + | |
| 396 | +/// Like [`central_page`], but with explicit top-level column specs. | |
| 397 | +pub fn central_page_cols( | |
| 398 | + ctx: &egui::Context, | |
| 399 | + theme: &Theme, | |
| 400 | + id: impl Hash, | |
| 401 | + cols: &[ColSpec], | |
| 402 | + add: impl FnOnce(&mut GridCtx<'_, '_>), | |
| 403 | +) -> egui::InnerResponse<()> { | |
| 404 | + egui::CentralPanel::default() | |
| 405 | + .frame(theme.page_frame()) | |
| 406 | + .show(ctx, |ui| { | |
| 407 | + page_body_cols(ui, theme, id, cols, add); | |
| 408 | + }) | |
| 409 | +} | |
| 410 | + | |
| 411 | +// ── Grid layout DSL ───────────────────────────────────────────────────────── | |
| 412 | +// | |
| 413 | +// Declarative rows/columns so apps do not hand-roll `egui::Grid`: | |
| 414 | +// | |
| 415 | +// ```ignore | |
| 416 | +// vidya::grid_cols(ui, &th, "procs", &[ | |
| 417 | +// ColSpec::Flex, | |
| 418 | +// ColSpec::Flex, | |
| 419 | +// ColSpec::MetricBps, | |
| 420 | +// ColSpec::MetricRate, | |
| 421 | +// ], |g| { | |
| 422 | +// g.row(|r| { | |
| 423 | +// r.heading("Name"); | |
| 424 | +// r.heading("Path"); | |
| 425 | +// r.heading("Write"); | |
| 426 | +// r.heading("Write freq"); | |
| 427 | +// }); | |
| 428 | +// g.row(|r| { | |
| 429 | +// r.text("chrome"); | |
| 430 | +// r.dim("/usr/bin/chrome"); | |
| 431 | +// r.metric_bps(write); | |
| 432 | +// r.metric_rate(freq); | |
| 433 | +// }); | |
| 434 | +// }); | |
| 435 | +// ``` | |
| 436 | + | |
| 437 | +/// Column width hint for the grid DSL. | |
| 438 | +#[derive(Debug, Clone, Copy)] | |
| 439 | +pub enum ColSpec { | |
| 440 | + /// Grow / shrink with content and leftover space. | |
| 441 | + Flex, | |
| 442 | + /// Fixed pixel width (e.g. custom metric column). | |
| 443 | + Fixed(f32), | |
| 444 | + /// Throughput metric column width from theme. | |
| 445 | + MetricBps, | |
| 446 | + /// Event-rate metric column width from theme. | |
| 447 | + MetricRate, | |
| 448 | +} | |
| 449 | + | |
| 450 | +impl ColSpec { | |
| 451 | + /// Desired / minimum width for fixed metric columns; `None` = flex. | |
| 452 | + pub fn px(self, theme: &Theme) -> Option<f32> { | |
| 453 | + match self { | |
| 454 | + ColSpec::Flex => None, | |
| 455 | + ColSpec::Fixed(w) => Some(w), | |
| 456 | + ColSpec::MetricBps => Some(metric_cell_px(theme, METRIC_BPS_CHARS)), | |
| 457 | + ColSpec::MetricRate => Some(metric_cell_px(theme, METRIC_RATE_CHARS)), | |
| 458 | + } | |
| 459 | + } | |
| 460 | +} | |
| 461 | + | |
| 462 | +/// Minimum width (px) reserved for a flex column when distributing space. | |
| 463 | +pub const FLEX_COL_MIN_PX: f32 = 48.0; | |
| 464 | + | |
| 465 | +/// Distribute per-column **max** widths so `sum(widths) + gaps ≤ avail`. | |
| 466 | +/// | |
| 467 | +/// - Fixed specs (`Some(w)`) keep `w` when the budget allows; otherwise they | |
| 468 | +/// scale down proportionally after flex mins are reserved. | |
| 469 | +/// - Flex specs (`None`) share the remaining budget equally (each ≥ [`FLEX_COL_MIN_PX`] | |
| 470 | +/// when possible). | |
| 471 | +/// | |
| 472 | +/// Pure policy — unit-tested without a window. | |
| 473 | +pub fn distribute_col_max(specs: &[Option<f32>], avail: f32, gap: f32) -> Vec<f32> { | |
| 474 | + let n = specs.len().max(1); | |
| 475 | + let gaps = gap * (n.saturating_sub(1) as f32); | |
| 476 | + let budget = (avail.max(1.0) - gaps).max(1.0); | |
| 477 | + | |
| 478 | + // No explicit specs → equal flex slices. | |
| 479 | + if specs.is_empty() { | |
| 480 | + return vec![budget / n as f32; n]; | |
| 481 | + } | |
| 482 | + | |
| 483 | + let flex_n = specs.iter().filter(|s| s.is_none()).count(); | |
| 484 | + let fixed_sum: f32 = specs.iter().filter_map(|s| *s).sum(); | |
| 485 | + | |
| 486 | + if flex_n == 0 { | |
| 487 | + if fixed_sum <= budget { | |
| 488 | + return specs.iter().map(|s| s.unwrap_or(0.0)).collect(); | |
| 489 | + } | |
| 490 | + let scale = budget / fixed_sum.max(1.0); | |
| 491 | + return specs.iter().map(|s| s.unwrap_or(0.0) * scale).collect(); | |
| 492 | + } | |
| 493 | + | |
| 494 | + let flex_floor = FLEX_COL_MIN_PX * flex_n as f32; | |
| 495 | + let fixed_budget = if fixed_sum + flex_floor <= budget { | |
| 496 | + fixed_sum | |
| 497 | + } else { | |
| 498 | + (budget - flex_floor).max(0.0) | |
| 499 | + }; | |
| 500 | + let fixed_scale = if fixed_sum > fixed_budget && fixed_sum > 0.0 { | |
| 501 | + fixed_budget / fixed_sum | |
| 502 | + } else { | |
| 503 | + 1.0 | |
| 504 | + }; | |
| 505 | + | |
| 506 | + let mut out = vec![0.0_f32; n]; | |
| 507 | + let mut used_fixed = 0.0_f32; | |
| 508 | + for (i, s) in specs.iter().enumerate() { | |
| 509 | + if let Some(w) = s { | |
| 510 | + out[i] = (*w * fixed_scale).max(1.0); | |
| 511 | + used_fixed += out[i]; | |
| 512 | + } | |
| 513 | + } | |
| 514 | + let flex_each = ((budget - used_fixed) / flex_n as f32).max(1.0); | |
| 515 | + for (i, s) in specs.iter().enumerate() { | |
| 516 | + if s.is_none() { | |
| 517 | + out[i] = flex_each; | |
| 518 | + } | |
| 519 | + } | |
| 520 | + out | |
| 521 | +} | |
| 522 | + | |
| 523 | +/// Live grid session (inside `egui::Grid`). | |
| 524 | +pub struct GridCtx<'ui, 'th> { | |
| 525 | + ui: &'ui mut Ui, | |
| 526 | + theme: &'th Theme, | |
| 527 | + /// Desired floors for fixed columns (`None` = flex). | |
| 528 | + col_widths: Vec<Option<f32>>, | |
| 529 | + /// Hard max width per column so the grid fits the residual viewport. | |
| 530 | + col_max: Vec<f32>, | |
| 531 | +} | |
| 532 | + | |
| 533 | +/// Options for [`grid_cols_with`] / the page shell grid. | |
| 534 | +#[derive(Debug, Clone, Copy)] | |
| 535 | +pub struct GridOpts { | |
| 536 | + /// Zebra striping (tables on; page shell off). | |
| 537 | + pub striped: bool, | |
| 538 | + /// Column gap (x) and row gap (y). | |
| 539 | + pub spacing: Vec2, | |
| 540 | +} | |
| 541 | + | |
| 542 | +impl GridOpts { | |
| 543 | + /// Defaults for nested data tables / multi-column surfaces. | |
| 544 | + pub fn table(theme: &Theme) -> Self { | |
| 545 | + Self { | |
| 546 | + striped: true, | |
| 547 | + spacing: Vec2::new(theme.spacing.md, 2.0), | |
| 548 | + } | |
| 549 | + } | |
| 550 | + | |
| 551 | + /// Defaults for top-level [`page_body`] (no striping, section-sized row gap). | |
| 552 | + pub fn page(theme: &Theme) -> Self { | |
| 553 | + Self { | |
| 554 | + striped: false, | |
| 555 | + spacing: Vec2::new(theme.spacing.md, theme.spacing.lg), | |
| 556 | + } | |
| 557 | + } | |
| 558 | +} | |
| 559 | + | |
| 560 | +/// Grid with explicit column specs (recommended for metric tables). | |
| 561 | +/// | |
| 562 | +/// The grid container is pinned to `ui.available_width()` so it cannot grow | |
| 563 | +/// past the parent / viewport residual. Column max widths are distributed via | |
| 564 | +/// [`distribute_col_max`]. | |
| 565 | +pub fn grid_cols( | |
| 566 | + ui: &mut Ui, | |
| 567 | + theme: &Theme, | |
| 568 | + id: impl Hash, | |
| 569 | + cols: &[ColSpec], | |
| 570 | + add: impl FnOnce(&mut GridCtx<'_, '_>), | |
| 571 | +) { | |
| 572 | + grid_cols_with(ui, theme, id, cols, GridOpts::table(theme), add); | |
| 573 | +} | |
| 574 | + | |
| 575 | +/// Grid with explicit column specs and layout options. | |
| 576 | +pub fn grid_cols_with( | |
| 577 | + ui: &mut Ui, | |
| 578 | + theme: &Theme, | |
| 579 | + id: impl Hash, | |
| 580 | + cols: &[ColSpec], | |
| 581 | + opts: GridOpts, | |
| 582 | + add: impl FnOnce(&mut GridCtx<'_, '_>), | |
| 583 | +) { | |
| 584 | + let avail = ui.available_width().max(1.0); | |
| 585 | + let n = if cols.is_empty() { 16 } else { cols.len() }; | |
| 586 | + let col_widths: Vec<Option<f32>> = cols.iter().map(|c| c.px(theme)).collect(); | |
| 587 | + let spacing = opts.spacing; | |
| 588 | + let col_max = if cols.is_empty() { | |
| 589 | + distribute_col_max(&vec![None; n], avail, spacing.x) | |
| 590 | + } else { | |
| 591 | + distribute_col_max(&col_widths, avail, spacing.x) | |
| 592 | + }; | |
| 593 | + let cell_max = col_max.iter().copied().fold(24.0_f32, f32::max); | |
| 594 | + | |
| 595 | + // Pin container to residual width (no early clip_rect — that can zero out | |
| 596 | + // height before layout and blank the whole page). | |
| 597 | + ui.scope(|ui| { | |
| 598 | + ui.set_max_width(avail); | |
| 599 | + Grid::new(id) | |
| 600 | + .num_columns(n) | |
| 601 | + .spacing(spacing) | |
| 602 | + .min_col_width(24.0) | |
| 603 | + .max_col_width(cell_max) | |
| 604 | + .striped(opts.striped) | |
| 605 | + .show(ui, |ui| { | |
| 606 | + ui.set_max_width(avail); | |
| 607 | + let mut ctx = GridCtx { | |
| 608 | + ui, | |
| 609 | + theme, | |
| 610 | + col_widths, | |
| 611 | + col_max, | |
| 612 | + }; | |
| 613 | + add(&mut ctx); | |
| 614 | + }); | |
| 615 | + }); | |
| 616 | +} | |
| 617 | + | |
| 618 | +/// Grid with all-flex columns. Prefer [`grid_cols`] when you have metrics. | |
| 619 | +pub fn grid(ui: &mut Ui, theme: &Theme, id: impl Hash, add: impl FnOnce(&mut GridCtx<'_, '_>)) { | |
| 620 | + grid_cols(ui, theme, id, &[], add); | |
| 621 | +} | |
| 622 | + | |
| 623 | +impl<'ui, 'th> GridCtx<'ui, 'th> { | |
| 624 | + /// Full-width page section: one row containing one cell. | |
| 625 | + /// | |
| 626 | + /// Primary building block for [`page_body`] / [`central_page`]. Put nested | |
| 627 | + /// grids, cards, and free-form widgets **inside** the section — not as | |
| 628 | + /// siblings of the page grid. | |
| 629 | + pub fn section(&mut self, add: impl FnOnce(&mut Ui)) { | |
| 630 | + self.row(|r| { | |
| 631 | + r.cell(add); | |
| 632 | + }); | |
| 633 | + } | |
| 634 | + | |
| 635 | + /// One table row. Cells are written left→right; `end_row` is automatic. | |
| 636 | + pub fn row(&mut self, add: impl FnOnce(&mut RowDsl<'_, 'th>)) { | |
| 637 | + let mut col_i = 0usize; | |
| 638 | + let mut row = RowDsl { | |
| 639 | + ui: self.ui, | |
| 640 | + theme: self.theme, | |
| 641 | + col_widths: &self.col_widths, | |
| 642 | + col_max: &self.col_max, | |
| 643 | + col_i: &mut col_i, | |
| 644 | + }; | |
| 645 | + add(&mut row); | |
| 646 | + self.ui.end_row(); | |
| 647 | + } | |
| 648 | + | |
| 649 | + /// Access the underlying grid `Ui` (escape hatch). | |
| 650 | + pub fn ui(&mut self) -> &mut Ui { | |
| 651 | + self.ui | |
| 652 | + } | |
| 653 | + | |
| 654 | + pub fn theme(&self) -> &Theme { | |
| 655 | + self.theme | |
| 656 | + } | |
| 657 | +} | |
| 658 | + | |
| 659 | +/// One row inside [`GridCtx::row`]. | |
| 660 | +pub struct RowDsl<'ui, 'th> { | |
| 661 | + ui: &'ui mut Ui, | |
| 662 | + theme: &'th Theme, | |
| 663 | + col_widths: &'ui [Option<f32>], | |
| 664 | + col_max: &'ui [f32], | |
| 665 | + col_i: &'ui mut usize, | |
| 666 | +} | |
| 667 | + | |
| 668 | +impl<'ui, 'th> RowDsl<'ui, 'th> { | |
| 669 | + fn advance(&mut self) { | |
| 670 | + *self.col_i += 1; | |
| 671 | + } | |
| 672 | + | |
| 673 | + fn width_hint(&self) -> Option<f32> { | |
| 674 | + self.col_widths.get(*self.col_i).copied().flatten() | |
| 675 | + } | |
| 676 | + | |
| 677 | + /// Hard max for this column (viewport residual budget). | |
| 678 | + fn col_max(&self) -> f32 { | |
| 679 | + self.col_max | |
| 680 | + .get(*self.col_i) | |
| 681 | + .copied() | |
| 682 | + .unwrap_or(self.ui.available_width().max(1.0)) | |
| 683 | + } | |
| 684 | + | |
| 685 | + fn metric_width(&self) -> f32 { | |
| 686 | + let floor = self | |
| 687 | + .width_hint() | |
| 688 | + .unwrap_or_else(|| metric_cell_px(self.theme, METRIC_BPS_CHARS)); | |
| 689 | + floor.min(self.col_max()).max(1.0) | |
| 690 | + } | |
| 691 | + | |
| 692 | + /// Free-form cell, sized to the column max width (top-aligned in the row). | |
| 693 | + pub fn cell(&mut self, add: impl FnOnce(&mut Ui)) { | |
| 694 | + let max_w = self.col_max().max(1.0); | |
| 695 | + // Width-capped, top-down group = one grid cell. | |
| 696 | + // | |
| 697 | + // Do **not** use `allocate_ui_with_layout(Vec2::new(max_w, 0.0), …)`. | |
| 698 | + // egui::Grid places desired sizes with `Align2::LEFT_CENTER`, so a | |
| 699 | + // zero-height seed is parked mid-row and content grows downward — | |
| 700 | + // leaving a large empty band above (page sections look vertically | |
| 701 | + // centered in the panel). `with_layout` uses the cell's available | |
| 702 | + // rect from the top of the row instead. | |
| 703 | + self.ui.with_layout(Layout::top_down(Align::Min), |ui| { | |
| 704 | + ui.set_min_width(max_w); | |
| 705 | + ui.set_max_width(max_w); | |
| 706 | + add(ui); | |
| 707 | + }); | |
| 708 | + self.advance(); | |
| 709 | + } | |
| 710 | + | |
| 711 | + /// Column header (caption, strong, secondary). Capped to column max. | |
| 712 | + pub fn heading(&mut self, text: &str) { | |
| 713 | + let size = self.theme.type_scale.caption; | |
| 714 | + let max_w = self.col_max(); | |
| 715 | + let rt = RichText::new(text) | |
| 716 | + .size(size) | |
| 717 | + .strong() | |
| 718 | + .color(self.theme.palette.text_secondary); | |
| 719 | + let need = measure_text(self.ui, text, size, false) + self.theme.spacing.sm; | |
| 720 | + let width = if let Some(hint) = self.width_hint() { | |
| 721 | + hint.max(need).min(max_w) | |
| 722 | + } else { | |
| 723 | + need.min(max_w) | |
| 724 | + }; | |
| 725 | + self.ui.allocate_ui_with_layout( | |
| 726 | + Vec2::new(width.max(1.0), size + 6.0), | |
| 727 | + Layout::right_to_left(Align::Center), | |
| 728 | + |ui| { | |
| 729 | + ui.set_max_width(width.max(1.0)); | |
| 730 | + ui.add(egui::Label::new(rt).truncate()); | |
| 731 | + }, | |
| 732 | + ); | |
| 733 | + self.advance(); | |
| 734 | + } | |
| 735 | + | |
| 736 | + /// Primary body text (flex) — truncates within column max. | |
| 737 | + pub fn text(&mut self, text: &str) { | |
| 738 | + table_text_capped(self.ui, self.theme, text, true, self.col_max()); | |
| 739 | + self.advance(); | |
| 740 | + } | |
| 741 | + | |
| 742 | + /// Secondary caption text (flex) — truncates within column max. | |
| 743 | + pub fn dim(&mut self, text: &str) { | |
| 744 | + table_text_capped(self.ui, self.theme, text, false, self.col_max()); | |
| 745 | + self.advance(); | |
| 746 | + } | |
| 747 | + | |
| 748 | + /// Warning-colored strong caption (e.g. anomaly process name). | |
| 749 | + pub fn warn(&mut self, text: &str) { | |
| 750 | + let max_w = self.col_max(); | |
| 751 | + let rt = RichText::new(text) | |
| 752 | + .size(self.theme.type_scale.caption) | |
| 753 | + .strong() | |
| 754 | + .color(self.theme.palette.warning); | |
| 755 | + self.ui.scope(|ui| { | |
| 756 | + ui.set_max_width(max_w); | |
| 757 | + ui.add(egui::Label::new(rt).truncate()); | |
| 758 | + }); | |
| 759 | + self.advance(); | |
| 760 | + } | |
| 761 | + | |
| 762 | + /// Right-aligned monospace metric (`text` from [`metric_bps`] / [`metric_rate`]). | |
| 763 | + pub fn metric(&mut self, text: &str) { | |
| 764 | + metric_cell(self.ui, self.theme, self.metric_width(), text, false); | |
| 765 | + self.advance(); | |
| 766 | + } | |
| 767 | + | |
| 768 | + /// Secondary (dim) metric. | |
| 769 | + pub fn metric_dim(&mut self, text: &str) { | |
| 770 | + metric_cell(self.ui, self.theme, self.metric_width(), text, true); | |
| 771 | + self.advance(); | |
| 772 | + } | |
| 773 | + | |
| 774 | + /// Throughput from raw B/s. | |
| 775 | + pub fn metric_bps(&mut self, bps: f64) { | |
| 776 | + let w = self | |
| 777 | + .width_hint() | |
| 778 | + .unwrap_or_else(|| metric_cell_px(self.theme, METRIC_BPS_CHARS)) | |
| 779 | + .min(self.col_max()) | |
| 780 | + .max(1.0); | |
| 781 | + metric_cell(self.ui, self.theme, w, &metric_bps(bps), false); | |
| 782 | + self.advance(); | |
| 783 | + } | |
| 784 | + | |
| 785 | + /// Event rate from raw 1/s. | |
| 786 | + pub fn metric_rate(&mut self, rate: f64) { | |
| 787 | + let w = self | |
| 788 | + .width_hint() | |
| 789 | + .unwrap_or_else(|| metric_cell_px(self.theme, METRIC_RATE_CHARS)) | |
| 790 | + .min(self.col_max()) | |
| 791 | + .max(1.0); | |
| 792 | + metric_cell(self.ui, self.theme, w, &metric_rate(rate), true); | |
| 793 | + self.advance(); | |
| 794 | + } | |
| 795 | +} | |
| 796 | + | |
| 797 | +// ── Metrics / tables ──────────────────────────────────────────────────────── | |
| 798 | + | |
| 799 | +/// Measure laid-out width of text (no wrap). | |
| 800 | +fn measure_text(ui: &Ui, text: &str, size: f32, mono: bool) -> f32 { | |
| 801 | + let family = if mono { | |
| 802 | + egui::FontFamily::Monospace | |
| 803 | + } else { | |
| 804 | + egui::FontFamily::Proportional | |
| 805 | + }; | |
| 806 | + let font = FontId::new(size, family); | |
| 807 | + ui.fonts(|f| { | |
| 808 | + f.layout_no_wrap(text.to_owned(), font, egui::Color32::WHITE) | |
| 809 | + .size() | |
| 810 | + .x | |
| 811 | + }) | |
| 812 | +} | |
| 813 | + | |
| 814 | +/// Measure monospace caption width for `text`. | |
| 815 | +pub fn measure_mono_caption(ui: &Ui, theme: &Theme, text: &str) -> f32 { | |
| 816 | + measure_text(ui, text, theme.type_scale.caption, true) | |
| 817 | +} | |
| 818 | + | |
| 819 | +/// Paint a monospace metric string, right-edge aligned. | |
| 820 | +/// | |
| 821 | +/// `min_width` is the cell size (from ColSpec floor, clamped by | |
| 822 | +/// [`distribute_col_max`] to the residual viewport). Text is clip-rect'd so it | |
| 823 | +/// never paints past the cell. | |
| 824 | +pub fn metric_cell(ui: &mut Ui, theme: &Theme, min_width: f32, text: &str, secondary: bool) { | |
| 825 | + let width = min_width.max(1.0); | |
| 826 | + let h = theme.type_scale.caption + 8.0; | |
| 827 | + let (rect, _) = ui.allocate_exact_size(Vec2::new(width, h), Sense::hover()); | |
| 828 | + if !ui.is_rect_visible(rect) { | |
| 829 | + return; | |
| 830 | + } | |
| 831 | + let color = if secondary { | |
| 832 | + theme.palette.text_secondary | |
| 833 | + } else { | |
| 834 | + theme.palette.text | |
| 835 | + }; | |
| 836 | + let font = FontId::monospace(theme.type_scale.caption); | |
| 837 | + let painter = ui.painter().with_clip_rect(rect); | |
| 838 | + let pos = egui::pos2(rect.right() - theme.spacing.xs.max(2.0), rect.center().y); | |
| 839 | + painter.text(pos, egui::Align2::RIGHT_CENTER, text, font, color); | |
| 840 | +} | |
| 841 | + | |
| 842 | +/// Column kind for [`data_table`] (thin table helper over the grid DSL). | |
| 843 | +#[derive(Debug, Clone, Copy)] | |
| 844 | +pub enum ColKind { | |
| 845 | + Flex, | |
| 846 | + Metric { width: f32 }, | |
| 847 | +} | |
| 848 | + | |
| 849 | +/// One column header + kind for [`data_table`]. | |
| 850 | +#[derive(Debug, Clone, Copy)] | |
| 851 | +pub struct Col { | |
| 852 | + pub header: &'static str, | |
| 853 | + pub kind: ColKind, | |
| 854 | +} | |
| 855 | + | |
| 856 | +/// Striped data table built on [`grid_cols`]. | |
| 857 | +/// | |
| 858 | +/// Prefer the row DSL (`grid_cols` + `g.row`) for new code; this keeps the | |
| 859 | +/// index-callback shape used by existing consumers. | |
| 860 | +pub fn data_table( | |
| 861 | + ui: &mut Ui, | |
| 862 | + theme: &Theme, | |
| 863 | + id: impl Hash, | |
| 864 | + columns: &[Col], | |
| 865 | + mut row: impl FnMut(&mut Ui, usize), | |
| 866 | + row_count: usize, | |
| 867 | +) { | |
| 868 | + let specs: Vec<ColSpec> = columns | |
| 869 | + .iter() | |
| 870 | + .map(|c| match c.kind { | |
| 871 | + ColKind::Flex => ColSpec::Flex, | |
| 872 | + ColKind::Metric { width } => ColSpec::Fixed(width), | |
| 873 | + }) | |
| 874 | + .collect(); | |
| 875 | + | |
| 876 | + // Same viewport pin + col-max policy as [`grid_cols`]. | |
| 877 | + let avail = ui.available_width().max(1.0); | |
| 878 | + let n = columns.len().max(1); | |
| 879 | + let col_widths: Vec<Option<f32>> = specs.iter().map(|c| c.px(theme)).collect(); | |
| 880 | + let spacing = Vec2::new(theme.spacing.md, 2.0); | |
| 881 | + let col_max = distribute_col_max(&col_widths, avail, spacing.x); | |
| 882 | + let cell_max = col_max.iter().copied().fold(40.0_f32, f32::max); | |
| 883 | + | |
| 884 | + ui.scope(|ui| { | |
| 885 | + ui.set_max_width(avail); | |
| 886 | + Grid::new(id) | |
| 887 | + .num_columns(n) | |
| 888 | + .spacing(spacing) | |
| 889 | + .min_col_width(24.0) | |
| 890 | + .max_col_width(cell_max) | |
| 891 | + .striped(true) | |
| 892 | + .show(ui, |ui| { | |
| 893 | + ui.set_max_width(avail); | |
| 894 | + let mut col_i = 0usize; | |
| 895 | + { | |
| 896 | + let mut r = RowDsl { | |
| 897 | + ui, | |
| 898 | + theme, | |
| 899 | + col_widths: &col_widths, | |
| 900 | + col_max: &col_max, | |
| 901 | + col_i: &mut col_i, | |
| 902 | + }; | |
| 903 | + for col in columns { | |
| 904 | + r.heading(col.header); | |
| 905 | + } | |
| 906 | + } | |
| 907 | + ui.end_row(); | |
| 908 | + for i in 0..row_count { | |
| 909 | + row(ui, i); | |
| 910 | + ui.end_row(); | |
| 911 | + } | |
| 912 | + }); | |
| 913 | + }); | |
| 914 | +} | |
| 915 | + | |
| 916 | +/// Flex text cell for [`data_table`] rows / grid rows (truncate to available). | |
| 917 | +pub fn table_text(ui: &mut Ui, theme: &Theme, text: &str, primary: bool) { | |
| 918 | + table_text_capped(ui, theme, text, primary, ui.available_width()); | |
| 919 | +} | |
| 920 | + | |
| 921 | +/// Flex text capped to `max_w` so grid columns stay within the viewport budget. | |
| 922 | +pub fn table_text_capped(ui: &mut Ui, theme: &Theme, text: &str, primary: bool, max_w: f32) { | |
| 923 | + let color = if primary { | |
| 924 | + theme.palette.text | |
| 925 | + } else { | |
| 926 | + theme.palette.text_secondary | |
| 927 | + }; | |
| 928 | + ui.scope(|ui| { | |
| 929 | + ui.set_max_width(max_w.max(1.0)); | |
| 930 | + ui.add( | |
| 931 | + egui::Label::new( | |
| 932 | + RichText::new(text) | |
| 933 | + .size(if primary { | |
| 934 | + theme.type_scale.body | |
| 935 | + } else { | |
| 936 | + theme.type_scale.caption | |
| 937 | + }) | |
| 938 | + .color(color), | |
| 939 | + ) | |
| 940 | + .truncate(), | |
| 941 | + ); | |
| 942 | + }); | |
| 943 | +} | |
| 944 | + | |
| 945 | +/// Metric cell for [`data_table`] rows (`text` should be [`metric_bps`] / [`metric_rate`]). | |
| 946 | +pub fn table_metric(ui: &mut Ui, theme: &Theme, width: f32, text: &str, secondary: bool) { | |
| 947 | + metric_cell(ui, theme, width, text, secondary); | |
| 948 | +} | |
| 949 | + | |
| 950 | +// ── Tests ─────────────────────────────────────────────────────────────────── | |
| 951 | + | |
| 952 | +#[cfg(test)] | |
| 953 | +mod tests { | |
| 954 | + use super::*; | |
| 955 | + use crate::Theme; | |
| 956 | + | |
| 957 | + #[test] | |
| 958 | + fn side_by_side_policy_matches_two_col_breakpoint() { | |
| 959 | + let gap = 12.0; | |
| 960 | + let min = 160.0; | |
| 961 | + assert!(!side_by_side(100.0, min, gap)); | |
| 962 | + assert!(!side_by_side(min * 2.0 + gap - 1.0, min, gap)); | |
| 963 | + assert!(side_by_side(min * 2.0 + gap, min, gap)); | |
| 964 | + assert!(side_by_side(800.0, min, gap)); | |
| 965 | + assert!(!side_by_side(800.0, 0.0, gap)); | |
| 966 | + } | |
| 967 | + | |
| 968 | + #[test] | |
| 969 | + fn metric_bps_fixed_char_width() { | |
| 970 | + for bps in [0.0, 100.0, 44.8 * 1024.0, 2.0 * 1024.0 * 1024.0, 999.0] { | |
| 971 | + let s = metric_bps(bps); | |
| 972 | + assert_eq!( | |
| 973 | + s.chars().count(), | |
| 974 | + METRIC_BPS_CHARS, | |
| 975 | + "metric_bps({bps}) = {s:?}" | |
| 976 | + ); | |
| 977 | + assert!( | |
| 978 | + s.ends_with(format_bps(bps).as_str()), | |
| 979 | + "padding must preserve value" | |
| 980 | + ); | |
| 981 | + } | |
| 982 | + } | |
| 983 | + | |
| 984 | + #[test] | |
| 985 | + fn metric_rate_fixed_char_width() { | |
| 986 | + for rate in [0.0, 1.5, 134.0, 2600.0, 1_500_000.0] { | |
| 987 | + let s = metric_rate(rate); | |
| 988 | + assert_eq!( | |
| 989 | + s.chars().count(), | |
| 990 | + METRIC_RATE_CHARS, | |
| 991 | + "metric_rate({rate}) = {s:?}" | |
| 992 | + ); | |
| 993 | + assert!( | |
| 994 | + s.contains(format_rate(rate).as_str()) || s.ends_with(format_rate(rate).as_str()) | |
| 995 | + ); | |
| 996 | + } | |
| 997 | + } | |
| 998 | + | |
| 999 | + #[test] | |
| 1000 | + fn pad_metric_is_identity_when_already_wide() { | |
| 1001 | + let long = "123456789012345"; // 15 > 14 | |
| 1002 | + assert_eq!(pad_metric(long, METRIC_BPS_CHARS), long); | |
| 1003 | + } | |
| 1004 | + | |
| 1005 | + #[test] | |
| 1006 | + fn metric_cell_px_scales_with_theme_caption() { | |
| 1007 | + let th = Theme::dark(); | |
| 1008 | + let a = metric_cell_px(&th, METRIC_BPS_CHARS); | |
| 1009 | + let b = metric_cell_px(&th, METRIC_RATE_CHARS); | |
| 1010 | + assert!(a > b); | |
| 1011 | + assert!(a > 40.0); | |
| 1012 | + // Floor must cover full padded glyph run (chars × 0.72em + pad). | |
| 1013 | + let floor = th.type_scale.caption * 0.72 * METRIC_BPS_CHARS as f32; | |
| 1014 | + assert!( | |
| 1015 | + a >= floor, | |
| 1016 | + "metric_cell_px={a} must be >= glyph floor {floor}" | |
| 1017 | + ); | |
| 1018 | + } | |
| 1019 | + | |
| 1020 | + #[test] | |
| 1021 | + fn metric_bps_string_never_exceeds_char_budget() { | |
| 1022 | + // Ensures the padded formatter and char budget stay in sync so | |
| 1023 | + // metric_cell_px floors remain meaningful. | |
| 1024 | + for bps in [0.0, 1.0, 512.0, 1024.0 * 50.0, 1024.0 * 1024.0 * 9.9] { | |
| 1025 | + let s = metric_bps(bps); | |
| 1026 | + assert!( | |
| 1027 | + s.chars().count() <= METRIC_BPS_CHARS || s.chars().count() == METRIC_BPS_CHARS, | |
| 1028 | + "unexpected width for {s:?}" | |
| 1029 | + ); | |
| 1030 | + assert_eq!(s.chars().count(), METRIC_BPS_CHARS); | |
| 1031 | + } | |
| 1032 | + } | |
| 1033 | + | |
| 1034 | + #[test] | |
| 1035 | + fn default_min_col_positive() { | |
| 1036 | + let th = Theme::dark(); | |
| 1037 | + assert!(default_min_col(&th) > 100.0); | |
| 1038 | + } | |
| 1039 | + | |
| 1040 | + #[test] | |
| 1041 | + fn col_spec_resolves_metric_widths() { | |
| 1042 | + let th = Theme::dark(); | |
| 1043 | + assert!(ColSpec::Flex.px(&th).is_none()); | |
| 1044 | + assert_eq!(ColSpec::Fixed(120.0).px(&th), Some(120.0)); | |
| 1045 | + let bps = ColSpec::MetricBps.px(&th).unwrap(); | |
| 1046 | + let rate = ColSpec::MetricRate.px(&th).unwrap(); | |
| 1047 | + assert!(bps > rate); | |
| 1048 | + assert!((bps - metric_cell_px(&th, METRIC_BPS_CHARS)).abs() < 0.01); | |
| 1049 | + } | |
| 1050 | + | |
| 1051 | + #[test] | |
| 1052 | + fn grid_opts_page_is_not_striped_and_uses_lg_row_gap() { | |
| 1053 | + let th = Theme::dark(); | |
| 1054 | + let page = GridOpts::page(&th); | |
| 1055 | + let table = GridOpts::table(&th); | |
| 1056 | + assert!(!page.striped); | |
| 1057 | + assert!(table.striped); | |
| 1058 | + assert!((page.spacing.y - th.spacing.lg).abs() < 0.01); | |
| 1059 | + assert!((table.spacing.y - 2.0).abs() < 0.01); | |
| 1060 | + } | |
| 1061 | + | |
| 1062 | + #[test] | |
| 1063 | + fn page_shell_api_is_grid_only() { | |
| 1064 | + // Source-level contract: page_body / central_page take GridCtx, not free Ui. | |
| 1065 | + let layout = include_str!("layout.rs"); | |
| 1066 | + // Signature block for page_body must include theme + GridCtx (not free Ui only). | |
| 1067 | + let start = layout | |
| 1068 | + .find("pub fn page_body(\n") | |
| 1069 | + .expect("page_body definition"); | |
| 1070 | + let sig = &layout[start..start + 280]; | |
| 1071 | + assert!( | |
| 1072 | + sig.contains("theme: &Theme"), | |
| 1073 | + "page_body must take theme for grid" | |
| 1074 | + ); | |
| 1075 | + assert!( | |
| 1076 | + sig.contains("GridCtx"), | |
| 1077 | + "page_body must take GridCtx callback: {sig}" | |
| 1078 | + ); | |
| 1079 | + assert!( | |
| 1080 | + !sig.contains("FnOnce(&mut Ui)"), | |
| 1081 | + "page_body must not accept free-form Ui: {sig}" | |
| 1082 | + ); | |
| 1083 | + assert!( | |
| 1084 | + layout.contains("page_body(ui, theme, id, add)"), | |
| 1085 | + "central_page must route through grid page_body" | |
| 1086 | + ); | |
| 1087 | + assert!( | |
| 1088 | + layout.contains("/// Full-width page section"), | |
| 1089 | + "GridCtx::section is the preferred page building block" | |
| 1090 | + ); | |
| 1091 | + // Escape hatch exists but is not the app path. | |
| 1092 | + assert!(layout.contains("pub fn page_scroll(")); | |
| 1093 | + } | |
| 1094 | + | |
| 1095 | + #[test] | |
| 1096 | + fn distribute_col_max_never_exceeds_budget() { | |
| 1097 | + let gap = 12.0; | |
| 1098 | + let specs = vec![None, None, Some(100.0), Some(80.0)]; | |
| 1099 | + for avail in [200.0_f32, 400.0, 800.0, 100.0] { | |
| 1100 | + let maxes = distribute_col_max(&specs, avail, gap); | |
| 1101 | + let gaps = gap * (specs.len() - 1) as f32; | |
| 1102 | + let sum: f32 = maxes.iter().sum(); | |
| 1103 | + assert!( | |
| 1104 | + sum + gaps <= avail + 0.5, | |
| 1105 | + "sum={sum} gaps={gaps} avail={avail} maxes={maxes:?}" | |
| 1106 | + ); | |
| 1107 | + assert_eq!(maxes.len(), specs.len()); | |
| 1108 | + } | |
| 1109 | + } | |
| 1110 | + | |
| 1111 | + #[test] | |
| 1112 | + fn distribute_col_max_scales_fixed_when_tight() { | |
| 1113 | + let specs = vec![Some(200.0), Some(200.0)]; | |
| 1114 | + let maxes = distribute_col_max(&specs, 200.0, 0.0); | |
| 1115 | + assert!((maxes[0] + maxes[1] - 200.0).abs() < 0.01); | |
| 1116 | + assert!(maxes[0] < 200.0); | |
| 1117 | + } | |
| 1118 | + | |
| 1119 | + #[test] | |
| 1120 | + fn distribute_all_flex_equal() { | |
| 1121 | + let specs = vec![None, None, None, None]; | |
| 1122 | + let maxes = distribute_col_max(&specs, 400.0, 0.0); | |
| 1123 | + for w in &maxes { | |
| 1124 | + assert!((*w - 100.0).abs() < 0.01); | |
| 1125 | + } | |
| 1126 | + } | |
| 1127 | + | |
| 1128 | + #[test] | |
| 1129 | + fn card_frame_chrome_x_is_margins_plus_stroke() { | |
| 1130 | + let th = Theme::dark(); | |
| 1131 | + // md*2 + 1px stroke each side | |
| 1132 | + assert!((card_frame_chrome_x(&th) - (th.spacing.md * 2.0 + 2.0)).abs() < 0.01); | |
| 1133 | + assert!(card_frame_chrome_x(&th) > th.spacing.md); | |
| 1134 | + } | |
| 1135 | + | |
| 1136 | + #[test] | |
| 1137 | + fn four_flex_cols_fit_viewport_budget() { | |
| 1138 | + // Same shape as the usage gauge row. | |
| 1139 | + let gap = 12.0; | |
| 1140 | + let avail = 700.0; | |
| 1141 | + let specs = vec![None, None, None, None]; | |
| 1142 | + let maxes = distribute_col_max(&specs, avail, gap); | |
| 1143 | + let sum: f32 = maxes.iter().sum::<f32>() + gap * 3.0; | |
| 1144 | + assert!(sum <= avail + 0.01, "sum={sum} avail={avail}"); | |
| 1145 | + for w in maxes { | |
| 1146 | + assert!(w > 50.0); | |
| 1147 | + } | |
| 1148 | + } | |
| 1149 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,1149 @@ | |||
| 1 | +//! Layout composition surface — width-safe, compact-by-default primitives. | ||
| 2 | +//! | ||
| 3 | +//! Apps should prefer these helpers over raw `set_max_width` / `Layout::…` | ||
| 4 | +//! plumbing. Escape hatches stay (egui is still available); this module makes | ||
| 5 | +//! the **good defaults** the short path. | ||
| 6 | +//! | ||
| 7 | +//! # Patterns → primitives | ||
| 8 | +//! | ||
| 9 | +//! | App footgun | Primitive | | ||
| 10 | +//! |-------------|-----------| | ||
| 11 | +//! | Page scrolls off / under edge | [`page_body`] / [`central_page`] | | ||
| 12 | +//! | Free-stack app UI at top level | **enforced:** [`page_body`] / [`central_page`] take a grid DSL | | ||
| 13 | +//! | Card overflows column | [`card`] / [`compact_card`] | | ||
| 14 | +//! | Giant gaps when parent is tall | [`vstack`] (non-justified) | | ||
| 15 | +//! | Fixed tiles stretch across the window | [`pack`] (wrap, hug content) | | ||
| 16 | +//! | Actions clipped off the right | [`lead_trail`] | | ||
| 17 | +//! | Side-by-side vs stack breakpoint | [`two_col`] / [`side_by_side`] | | ||
| 18 | +//! | Rate columns staircase ("waterfall") | [`metric_bps`] / [`metric_rate`] / [`metric_cell`] / [`grid_cols`] / [`data_table`] | | ||
| 19 | +//! | ||
| 20 | +//! # Top-level application UI (enforced) | ||
| 21 | +//! | ||
| 22 | +//! Application content in the central panel **must** be composed through the | ||
| 23 | +//! grid DSL. [`page_body`] and [`central_page`] only accept a [`GridCtx`] | ||
| 24 | +//! callback — free stacking of widgets at the page root is not part of the | ||
| 25 | +//! public path. Nested cards, nested [`grid_cols`], gauges, and text go | ||
| 26 | +//! **inside** cells / [`GridCtx::section`]s. | ||
| 27 | +//! | ||
| 28 | +//! ```ignore | ||
| 29 | +//! vidya::central_page(ctx, &th, "main", |g| { | ||
| 30 | +//! g.section(|ui| { /* gauges row — often a nested grid_cols */ }); | ||
| 31 | +//! g.section(|ui| { /* process table */ }); | ||
| 32 | +//! }); | ||
| 33 | +//! ``` | ||
| 34 | +//! | ||
| 35 | +//! Escape hatch (scroll + width pin only, no grid): [`page_scroll`]. | ||
| 36 | + | ||
| 37 | +use std::hash::Hash; | ||
| 38 | + | ||
| 39 | +use egui::{ | ||
| 40 | + Align, FontId, Frame, Grid, InnerResponse, Layout, Margin, RichText, ScrollArea, Sense, Stroke, | ||
| 41 | + Ui, Vec2, | ||
| 42 | +}; | ||
| 43 | + | ||
| 44 | +use crate::Theme; | ||
| 45 | + | ||
| 46 | +// ── Pure policy (unit-tested without a window) ────────────────────────────── | ||
| 47 | + | ||
| 48 | +/// Minimum residual width (px) before two equal columns fit with `gap`. | ||
| 49 | +/// | ||
| 50 | +/// `true` means place side-by-side; `false` means stack vertically. | ||
| 51 | +pub fn side_by_side(avail: f32, min_col: f32, gap: f32) -> bool { | ||
| 52 | + avail >= min_col * 2.0 + gap && min_col > 0.0 && avail > 0.0 | ||
| 53 | +} | ||
| 54 | + | ||
| 55 | +/// Default min column width for [`two_col`] when apps pass theme spacing. | ||
| 56 | +pub fn default_min_col(theme: &Theme) -> f32 { | ||
| 57 | + // Roughly one compact card: control + padding. | ||
| 58 | + theme.spacing.control_height * 6.0 + theme.spacing.page | ||
| 59 | +} | ||
| 60 | + | ||
| 61 | +/// Character width of a fixed monospace throughput cell ([`metric_bps`]). | ||
| 62 | +pub const METRIC_BPS_CHARS: usize = 14; | ||
| 63 | + | ||
| 64 | +/// Character width of a fixed monospace event-rate cell ([`metric_rate`]). | ||
| 65 | +pub const METRIC_RATE_CHARS: usize = 10; | ||
| 66 | + | ||
| 67 | +/// Minimum pixel width for a metric cell of `chars` monospace glyphs + padding. | ||
| 68 | +/// | ||
| 69 | +/// Used as a **floor** for [`ColSpec::MetricBps`] / [`MetricRate`]. Actual cells | ||
| 70 | +/// still grow via [`metric_cell`] if the painted string is wider. | ||
| 71 | +pub fn metric_cell_px(theme: &Theme, chars: usize) -> f32 { | ||
| 72 | + // Monospace advance is typically ~0.6–0.65em; use 0.72 so columns never | ||
| 73 | + // undershoot common caption sizes (was 0.62 and clipped padded metrics). | ||
| 74 | + let advance = theme.type_scale.caption * 0.72; | ||
| 75 | + let pad = theme.spacing.md + theme.spacing.sm; | ||
| 76 | + advance * chars as f32 + pad | ||
| 77 | +} | ||
| 78 | + | ||
| 79 | +/// Left-pad `s` to exactly `width` characters (Unicode scalar count). | ||
| 80 | +/// | ||
| 81 | +/// Longer strings are returned unchanged (width is a minimum for alignment). | ||
| 82 | +pub fn pad_metric(s: &str, width: usize) -> String { | ||
| 83 | + let n = s.chars().count(); | ||
| 84 | + if n >= width { | ||
| 85 | + s.to_string() | ||
| 86 | + } else { | ||
| 87 | + format!("{:>width$}", s, width = width) | ||
| 88 | + } | ||
| 89 | +} | ||
| 90 | + | ||
| 91 | +/// Human-readable throughput (B/s → KiB/s, …) without padding. | ||
| 92 | +pub fn format_bps(bps: f64) -> String { | ||
| 93 | + const UNITS: [&str; 5] = ["B/s", "KiB/s", "MiB/s", "GiB/s", "TiB/s"]; | ||
| 94 | + let mut v = bps.max(0.0); | ||
| 95 | + let mut i = 0; | ||
| 96 | + while v >= 1024.0 && i < UNITS.len() - 1 { | ||
| 97 | + v /= 1024.0; | ||
| 98 | + i += 1; | ||
| 99 | + } | ||
| 100 | + if i == 0 { | ||
| 101 | + format!("{v:.0} {}", UNITS[i]) | ||
| 102 | + } else if v >= 100.0 { | ||
| 103 | + format!("{v:.0} {}", UNITS[i]) | ||
| 104 | + } else if v >= 10.0 { | ||
| 105 | + format!("{v:.1} {}", UNITS[i]) | ||
| 106 | + } else { | ||
| 107 | + format!("{v:.2} {}", UNITS[i]) | ||
| 108 | + } | ||
| 109 | +} | ||
| 110 | + | ||
| 111 | +/// Fixed-width throughput for tables (monospace-safe; both edges flush). | ||
| 112 | +pub fn metric_bps(bps: f64) -> String { | ||
| 113 | + pad_metric(&format_bps(bps), METRIC_BPS_CHARS) | ||
| 114 | +} | ||
| 115 | + | ||
| 116 | +/// Human-readable event rate (e.g. write syscalls/s) without padding. | ||
| 117 | +pub fn format_rate(rate: f64) -> String { | ||
| 118 | + if rate < 0.05 { | ||
| 119 | + "0/s".into() | ||
| 120 | + } else if rate < 10.0 { | ||
| 121 | + format!("{rate:.1}/s") | ||
| 122 | + } else if rate < 1000.0 { | ||
| 123 | + format!("{rate:.0}/s") | ||
| 124 | + } else if rate < 1_000_000.0 { | ||
| 125 | + format!("{:.1}k/s", rate / 1000.0) | ||
| 126 | + } else { | ||
| 127 | + format!("{:.1}M/s", rate / 1_000_000.0) | ||
| 128 | + } | ||
| 129 | +} | ||
| 130 | + | ||
| 131 | +/// Fixed-width event rate for tables. | ||
| 132 | +pub fn metric_rate(rate: f64) -> String { | ||
| 133 | + pad_metric(&format_rate(rate), METRIC_RATE_CHARS) | ||
| 134 | +} | ||
| 135 | + | ||
| 136 | +// ── Width-safe scopes ─────────────────────────────────────────────────────── | ||
| 137 | + | ||
| 138 | +/// Pin this scope so children cannot expand past the current available width. | ||
| 139 | +pub fn fit_width(ui: &mut Ui, add: impl FnOnce(&mut Ui)) -> InnerResponse<()> { | ||
| 140 | + let w = ui.available_width().max(1.0); | ||
| 141 | + ui.scope(|ui| { | ||
| 142 | + ui.set_max_width(w); | ||
| 143 | + add(ui); | ||
| 144 | + }) | ||
| 145 | +} | ||
| 146 | + | ||
| 147 | +/// Same as [`fit_width`], but also set `min_width` so framed children fill the residual. | ||
| 148 | +pub fn fill_width(ui: &mut Ui, add: impl FnOnce(&mut Ui)) -> InnerResponse<()> { | ||
| 149 | + let w = ui.available_width().max(1.0); | ||
| 150 | + ui.scope(|ui| { | ||
| 151 | + ui.set_min_width(w); | ||
| 152 | + ui.set_max_width(w); | ||
| 153 | + add(ui); | ||
| 154 | + }) | ||
| 155 | +} | ||
| 156 | + | ||
| 157 | +/// Non-justified vertical stack — **no giant gaps** when the parent is taller | ||
| 158 | +/// than the content (the usual egui “waterfall” inside stretched cards). | ||
| 159 | +pub fn vstack(ui: &mut Ui, theme: &Theme, add: impl FnOnce(&mut Ui)) -> InnerResponse<()> { | ||
| 160 | + let gap = theme.spacing.sm; | ||
| 161 | + ui.with_layout(Layout::top_down(Align::Min), |ui| { | ||
| 162 | + ui.spacing_mut().item_spacing = Vec2::new(ui.spacing().item_spacing.x, gap); | ||
| 163 | + add(ui); | ||
| 164 | + }) | ||
| 165 | +} | ||
| 166 | + | ||
| 167 | +// ── Surfaces ──────────────────────────────────────────────────────────────── | ||
| 168 | + | ||
| 169 | +/// Themed card that **fills** the parent column without overflowing past it. | ||
| 170 | +/// | ||
| 171 | +/// Content is stacked with [`vstack`] so tall parents do not justify gaps. | ||
| 172 | +pub fn card(ui: &mut Ui, theme: &Theme, add: impl FnOnce(&mut Ui)) -> InnerResponse<()> { | ||
| 173 | + let outer = ui.available_width().max(1.0); | ||
| 174 | + ui.set_max_width(outer); | ||
| 175 | + theme.card_frame().show(ui, |ui| { | ||
| 176 | + let inner = ui.available_width().max(1.0); | ||
| 177 | + ui.set_min_width(inner); | ||
| 178 | + ui.set_max_width(inner); | ||
| 179 | + vstack(ui, theme, add); | ||
| 180 | + }) | ||
| 181 | +} | ||
| 182 | + | ||
| 183 | +/// Horizontal chrome (inner margin + stroke) added by [`Theme::card_frame`]. | ||
| 184 | +/// | ||
| 185 | +/// Outer card width = content width + this value. Pure / testable. | ||
| 186 | +pub fn card_frame_chrome_x(theme: &Theme) -> f32 { | ||
| 187 | + // card_frame: Margin::same(spacing.md) + Stroke width 1 on each side. | ||
| 188 | + theme.spacing.md * 2.0 + 2.0 | ||
| 189 | +} | ||
| 190 | + | ||
| 191 | +/// Compact card with a **fixed outer width** that hugs content height. | ||
| 192 | +/// | ||
| 193 | +/// Use for gauge tiles and anomaly panels that must not stretch across the | ||
| 194 | +/// window or absorb leftover horizontal space between siblings. | ||
| 195 | +/// | ||
| 196 | +/// Outer size is clamped to residual width; content width subtracts frame | ||
| 197 | +/// chrome so the painted card never exceeds the budget (avoids grid overflow). | ||
| 198 | +pub fn compact_card( | ||
| 199 | + ui: &mut Ui, | ||
| 200 | + theme: &Theme, | ||
| 201 | + width: f32, | ||
| 202 | + add: impl FnOnce(&mut Ui), | ||
| 203 | +) -> InnerResponse<()> { | ||
| 204 | + let chrome = card_frame_chrome_x(theme); | ||
| 205 | + // Outer size must fit the residual (grid cell / viewport). | ||
| 206 | + let outer = width.min(ui.available_width()).max(1.0); | ||
| 207 | + let inner = (outer - chrome).max(1.0); | ||
| 208 | + | ||
| 209 | + ui.allocate_ui_with_layout(Vec2::new(outer, 0.0), Layout::top_down(Align::Min), |ui| { | ||
| 210 | + ui.set_min_width(outer); | ||
| 211 | + ui.set_max_width(outer); | ||
| 212 | + // Do NOT set_clip_rect here: max_rect starts with height 0 before | ||
| 213 | + // children run, which would hide all content (blank window). | ||
| 214 | + theme.card_frame().show(ui, |ui| { | ||
| 215 | + ui.set_min_width(inner); | ||
| 216 | + ui.set_max_width(inner); | ||
| 217 | + vstack(ui, theme, add); | ||
| 218 | + }); | ||
| 219 | + }) | ||
| 220 | +} | ||
| 221 | + | ||
| 222 | +/// Soft-bordered inset row (popover surface) capped to parent width. | ||
| 223 | +pub fn inset_row(ui: &mut Ui, theme: &Theme, add: impl FnOnce(&mut Ui)) -> InnerResponse<()> { | ||
| 224 | + let outer = ui.available_width().max(1.0); | ||
| 225 | + ui.set_max_width(outer); | ||
| 226 | + Frame::new() | ||
| 227 | + .fill(theme.palette.popover_bg) | ||
| 228 | + .stroke(Stroke::new(1.0, theme.palette.border_soft)) | ||
| 229 | + .corner_radius(theme.spacing.radius_sm) | ||
| 230 | + .inner_margin(Margin::symmetric( | ||
| 231 | + theme.spacing.md as i8, | ||
| 232 | + theme.spacing.sm as i8, | ||
| 233 | + )) | ||
| 234 | + .show(ui, |ui| { | ||
| 235 | + let w = ui.available_width().max(1.0); | ||
| 236 | + ui.set_min_width(w); | ||
| 237 | + ui.set_max_width(w); | ||
| 238 | + vstack(ui, theme, add); | ||
| 239 | + }) | ||
| 240 | +} | ||
| 241 | + | ||
| 242 | +// ── Horizontal composition ────────────────────────────────────────────────── | ||
| 243 | + | ||
| 244 | +/// Horizontal flow that **wraps** before clipping the edge (toolbars / chips). | ||
| 245 | +pub fn hflow(ui: &mut Ui, theme: &Theme, add: impl FnOnce(&mut Ui)) -> InnerResponse<()> { | ||
| 246 | + let w = ui.available_width().max(1.0); | ||
| 247 | + let gap = theme.spacing.sm; | ||
| 248 | + ui.scope(|ui| { | ||
| 249 | + ui.set_max_width(w); | ||
| 250 | + ui.spacing_mut().item_spacing = Vec2::new(gap, gap); | ||
| 251 | + ui.horizontal_wrapped(add); | ||
| 252 | + }) | ||
| 253 | +} | ||
| 254 | + | ||
| 255 | +/// Pack of **compact** children (fixed-size cards/tiles) that wrap without | ||
| 256 | +/// stretching leftover horizontal space into empty gaps between items. | ||
| 257 | +/// | ||
| 258 | +/// Same wrapping as [`hflow`], but spacing defaults to `md` so packs match | ||
| 259 | +/// gauge / anomaly card groups. | ||
| 260 | +pub fn pack(ui: &mut Ui, theme: &Theme, add: impl FnOnce(&mut Ui)) -> InnerResponse<()> { | ||
| 261 | + let w = ui.available_width().max(1.0); | ||
| 262 | + let gap = theme.spacing.md; | ||
| 263 | + ui.scope(|ui| { | ||
| 264 | + ui.set_max_width(w); | ||
| 265 | + ui.spacing_mut().item_spacing = Vec2::new(gap, gap); | ||
| 266 | + ui.horizontal_wrapped(add); | ||
| 267 | + }) | ||
| 268 | +} | ||
| 269 | + | ||
| 270 | +/// Leading content grows into remaining width; trailing actions stay visible. | ||
| 271 | +/// | ||
| 272 | +/// Height hugs content. Do **not** use bare `with_layout(… Align::Center)` here: | ||
| 273 | +/// egui expands centered horizontal frames to the parent's full available | ||
| 274 | +/// height, so `min_rect` eats the panel and everything below (toolbars, | ||
| 275 | +/// tables, scroll areas) gets zero height — a blank detail pane. | ||
| 276 | +pub fn lead_trail( | ||
| 277 | + ui: &mut Ui, | ||
| 278 | + leading: impl FnOnce(&mut Ui), | ||
| 279 | + trailing: impl FnOnce(&mut Ui), | ||
| 280 | +) -> InnerResponse<()> { | ||
| 281 | + let w = ui.available_width().max(1.0); | ||
| 282 | + ui.allocate_ui_with_layout( | ||
| 283 | + Vec2::new(w, 0.0), | ||
| 284 | + Layout::right_to_left(Align::Center), | ||
| 285 | + |ui| { | ||
| 286 | + trailing(ui); | ||
| 287 | + let rest = ui.available_width().max(1.0); | ||
| 288 | + ui.allocate_ui_with_layout( | ||
| 289 | + Vec2::new(rest, 0.0), | ||
| 290 | + Layout::left_to_right(Align::Center), | ||
| 291 | + |ui| { | ||
| 292 | + ui.set_max_width(rest); | ||
| 293 | + ui.set_min_width(rest); | ||
| 294 | + leading(ui); | ||
| 295 | + }, | ||
| 296 | + ); | ||
| 297 | + }, | ||
| 298 | + ) | ||
| 299 | +} | ||
| 300 | + | ||
| 301 | +/// Two columns when [`side_by_side`] says so; otherwise stack. | ||
| 302 | +pub fn two_col( | ||
| 303 | + ui: &mut Ui, | ||
| 304 | + theme: &Theme, | ||
| 305 | + min_col: f32, | ||
| 306 | + left: impl FnOnce(&mut Ui), | ||
| 307 | + right: impl FnOnce(&mut Ui), | ||
| 308 | +) { | ||
| 309 | + let gap = theme.spacing.md; | ||
| 310 | + let avail = ui.available_width(); | ||
| 311 | + if side_by_side(avail, min_col, gap) { | ||
| 312 | + ui.columns(2, |cols| { | ||
| 313 | + cols[0].set_width(cols[0].available_width()); | ||
| 314 | + left(&mut cols[0]); | ||
| 315 | + cols[1].set_width(cols[1].available_width()); | ||
| 316 | + right(&mut cols[1]); | ||
| 317 | + }); | ||
| 318 | + } else { | ||
| 319 | + left(ui); | ||
| 320 | + ui.add_space(gap); | ||
| 321 | + right(ui); | ||
| 322 | + } | ||
| 323 | +} | ||
| 324 | + | ||
| 325 | +// ── Page shell ────────────────────────────────────────────────────────────── | ||
| 326 | +// | ||
| 327 | +// Application UI at the top level is **grid-only**. `page_body` / | ||
| 328 | +// `central_page` take a `GridCtx` callback so free-form stacking cannot be | ||
| 329 | +// the supported root composition path. Nested content lives inside cells. | ||
| 330 | + | ||
| 331 | +/// Escape hatch: pin width + vertical scroll **without** a top-level grid. | ||
| 332 | +/// | ||
| 333 | +/// Prefer [`page_body`] / [`central_page`] for application UI. Use this only | ||
| 334 | +/// when a demo or special chrome needs free-form scrolling content. | ||
| 335 | +pub fn page_scroll(ui: &mut Ui, add: impl FnOnce(&mut Ui)) { | ||
| 336 | + let w = ui.available_width().max(1.0); | ||
| 337 | + ui.set_max_width(w); | ||
| 338 | + ScrollArea::vertical() | ||
| 339 | + .auto_shrink([false, false]) | ||
| 340 | + .id_salt("vidya_page_scroll") | ||
| 341 | + .show(ui, |ui| { | ||
| 342 | + let inner = ui.available_width().max(1.0); | ||
| 343 | + ui.set_max_width(inner); | ||
| 344 | + add(ui); | ||
| 345 | + }); | ||
| 346 | +} | ||
| 347 | + | ||
| 348 | +/// Scrollable application page whose **top-level content is a grid**. | ||
| 349 | +/// | ||
| 350 | +/// Defaults to a single flex column: each [`GridCtx::section`] (or `g.row`) | ||
| 351 | +/// is a full-width page block. Nested multi-column layouts use [`grid_cols`] | ||
| 352 | +/// inside a section/cell. | ||
| 353 | +/// | ||
| 354 | +/// This is the supported root for app UI — the callback cannot receive a raw | ||
| 355 | +/// free-stack `Ui` at the page root. | ||
| 356 | +pub fn page_body( | ||
| 357 | + ui: &mut Ui, | ||
| 358 | + theme: &Theme, | ||
| 359 | + id: impl Hash, | ||
| 360 | + add: impl FnOnce(&mut GridCtx<'_, '_>), | ||
| 361 | +) { | ||
| 362 | + page_body_cols(ui, theme, id, &[ColSpec::Flex], add); | ||
| 363 | +} | ||
| 364 | + | ||
| 365 | +/// Like [`page_body`], but with explicit top-level column specs. | ||
| 366 | +pub fn page_body_cols( | ||
| 367 | + ui: &mut Ui, | ||
| 368 | + theme: &Theme, | ||
| 369 | + id: impl Hash, | ||
| 370 | + cols: &[ColSpec], | ||
| 371 | + add: impl FnOnce(&mut GridCtx<'_, '_>), | ||
| 372 | +) { | ||
| 373 | + page_scroll(ui, |ui| { | ||
| 374 | + // Page grid: no zebra striping, larger vertical gap between sections. | ||
| 375 | + grid_cols_with(ui, theme, id, cols, GridOpts::page(theme), add); | ||
| 376 | + }); | ||
| 377 | +} | ||
| 378 | + | ||
| 379 | +/// Full central page: themed page frame + grid-enforced [`page_body`]. | ||
| 380 | +/// | ||
| 381 | +/// Application central content **must** be composed via the grid DSL | ||
| 382 | +/// (`g.section` / `g.row`). See module docs. | ||
| 383 | +pub fn central_page( | ||
| 384 | + ctx: &egui::Context, | ||
| 385 | + theme: &Theme, | ||
| 386 | + id: impl Hash, | ||
| 387 | + add: impl FnOnce(&mut GridCtx<'_, '_>), | ||
| 388 | +) -> egui::InnerResponse<()> { | ||
| 389 | + egui::CentralPanel::default() | ||
| 390 | + .frame(theme.page_frame()) | ||
| 391 | + .show(ctx, |ui| { | ||
| 392 | + page_body(ui, theme, id, add); | ||
| 393 | + }) | ||
| 394 | +} | ||
| 395 | + | ||
| 396 | +/// Like [`central_page`], but with explicit top-level column specs. | ||
| 397 | +pub fn central_page_cols( | ||
| 398 | + ctx: &egui::Context, | ||
| 399 | + theme: &Theme, | ||
| 400 | + id: impl Hash, | ||
| 401 | + cols: &[ColSpec], | ||
| 402 | + add: impl FnOnce(&mut GridCtx<'_, '_>), | ||
| 403 | +) -> egui::InnerResponse<()> { | ||
| 404 | + egui::CentralPanel::default() | ||
| 405 | + .frame(theme.page_frame()) | ||
| 406 | + .show(ctx, |ui| { | ||
| 407 | + page_body_cols(ui, theme, id, cols, add); | ||
| 408 | + }) | ||
| 409 | +} | ||
| 410 | + | ||
| 411 | +// ── Grid layout DSL ───────────────────────────────────────────────────────── | ||
| 412 | +// | ||
| 413 | +// Declarative rows/columns so apps do not hand-roll `egui::Grid`: | ||
| 414 | +// | ||
| 415 | +// ```ignore | ||
| 416 | +// vidya::grid_cols(ui, &th, "procs", &[ | ||
| 417 | +// ColSpec::Flex, | ||
| 418 | +// ColSpec::Flex, | ||
| 419 | +// ColSpec::MetricBps, | ||
| 420 | +// ColSpec::MetricRate, | ||
| 421 | +// ], |g| { | ||
| 422 | +// g.row(|r| { | ||
| 423 | +// r.heading("Name"); | ||
| 424 | +// r.heading("Path"); | ||
| 425 | +// r.heading("Write"); | ||
| 426 | +// r.heading("Write freq"); | ||
| 427 | +// }); | ||
| 428 | +// g.row(|r| { | ||
| 429 | +// r.text("chrome"); | ||
| 430 | +// r.dim("/usr/bin/chrome"); | ||
| 431 | +// r.metric_bps(write); | ||
| 432 | +// r.metric_rate(freq); | ||
| 433 | +// }); | ||
| 434 | +// }); | ||
| 435 | +// ``` | ||
| 436 | + | ||
| 437 | +/// Column width hint for the grid DSL. | ||
| 438 | +#[derive(Debug, Clone, Copy)] | ||
| 439 | +pub enum ColSpec { | ||
| 440 | + /// Grow / shrink with content and leftover space. | ||
| 441 | + Flex, | ||
| 442 | + /// Fixed pixel width (e.g. custom metric column). | ||
| 443 | + Fixed(f32), | ||
| 444 | + /// Throughput metric column width from theme. | ||
| 445 | + MetricBps, | ||
| 446 | + /// Event-rate metric column width from theme. | ||
| 447 | + MetricRate, | ||
| 448 | +} | ||
| 449 | + | ||
| 450 | +impl ColSpec { | ||
| 451 | + /// Desired / minimum width for fixed metric columns; `None` = flex. | ||
| 452 | + pub fn px(self, theme: &Theme) -> Option<f32> { | ||
| 453 | + match self { | ||
| 454 | + ColSpec::Flex => None, | ||
| 455 | + ColSpec::Fixed(w) => Some(w), | ||
| 456 | + ColSpec::MetricBps => Some(metric_cell_px(theme, METRIC_BPS_CHARS)), | ||
| 457 | + ColSpec::MetricRate => Some(metric_cell_px(theme, METRIC_RATE_CHARS)), | ||
| 458 | + } | ||
| 459 | + } | ||
| 460 | +} | ||
| 461 | + | ||
| 462 | +/// Minimum width (px) reserved for a flex column when distributing space. | ||
| 463 | +pub const FLEX_COL_MIN_PX: f32 = 48.0; | ||
| 464 | + | ||
| 465 | +/// Distribute per-column **max** widths so `sum(widths) + gaps ≤ avail`. | ||
| 466 | +/// | ||
| 467 | +/// - Fixed specs (`Some(w)`) keep `w` when the budget allows; otherwise they | ||
| 468 | +/// scale down proportionally after flex mins are reserved. | ||
| 469 | +/// - Flex specs (`None`) share the remaining budget equally (each ≥ [`FLEX_COL_MIN_PX`] | ||
| 470 | +/// when possible). | ||
| 471 | +/// | ||
| 472 | +/// Pure policy — unit-tested without a window. | ||
| 473 | +pub fn distribute_col_max(specs: &[Option<f32>], avail: f32, gap: f32) -> Vec<f32> { | ||
| 474 | + let n = specs.len().max(1); | ||
| 475 | + let gaps = gap * (n.saturating_sub(1) as f32); | ||
| 476 | + let budget = (avail.max(1.0) - gaps).max(1.0); | ||
| 477 | + | ||
| 478 | + // No explicit specs → equal flex slices. | ||
| 479 | + if specs.is_empty() { | ||
| 480 | + return vec![budget / n as f32; n]; | ||
| 481 | + } | ||
| 482 | + | ||
| 483 | + let flex_n = specs.iter().filter(|s| s.is_none()).count(); | ||
| 484 | + let fixed_sum: f32 = specs.iter().filter_map(|s| *s).sum(); | ||
| 485 | + | ||
| 486 | + if flex_n == 0 { | ||
| 487 | + if fixed_sum <= budget { | ||
| 488 | + return specs.iter().map(|s| s.unwrap_or(0.0)).collect(); | ||
| 489 | + } | ||
| 490 | + let scale = budget / fixed_sum.max(1.0); | ||
| 491 | + return specs.iter().map(|s| s.unwrap_or(0.0) * scale).collect(); | ||
| 492 | + } | ||
| 493 | + | ||
| 494 | + let flex_floor = FLEX_COL_MIN_PX * flex_n as f32; | ||
| 495 | + let fixed_budget = if fixed_sum + flex_floor <= budget { | ||
| 496 | + fixed_sum | ||
| 497 | + } else { | ||
| 498 | + (budget - flex_floor).max(0.0) | ||
| 499 | + }; | ||
| 500 | + let fixed_scale = if fixed_sum > fixed_budget && fixed_sum > 0.0 { | ||
| 501 | + fixed_budget / fixed_sum | ||
| 502 | + } else { | ||
| 503 | + 1.0 | ||
| 504 | + }; | ||
| 505 | + | ||
| 506 | + let mut out = vec![0.0_f32; n]; | ||
| 507 | + let mut used_fixed = 0.0_f32; | ||
| 508 | + for (i, s) in specs.iter().enumerate() { | ||
| 509 | + if let Some(w) = s { | ||
| 510 | + out[i] = (*w * fixed_scale).max(1.0); | ||
| 511 | + used_fixed += out[i]; | ||
| 512 | + } | ||
| 513 | + } | ||
| 514 | + let flex_each = ((budget - used_fixed) / flex_n as f32).max(1.0); | ||
| 515 | + for (i, s) in specs.iter().enumerate() { | ||
| 516 | + if s.is_none() { | ||
| 517 | + out[i] = flex_each; | ||
| 518 | + } | ||
| 519 | + } | ||
| 520 | + out | ||
| 521 | +} | ||
| 522 | + | ||
| 523 | +/// Live grid session (inside `egui::Grid`). | ||
| 524 | +pub struct GridCtx<'ui, 'th> { | ||
| 525 | + ui: &'ui mut Ui, | ||
| 526 | + theme: &'th Theme, | ||
| 527 | + /// Desired floors for fixed columns (`None` = flex). | ||
| 528 | + col_widths: Vec<Option<f32>>, | ||
| 529 | + /// Hard max width per column so the grid fits the residual viewport. | ||
| 530 | + col_max: Vec<f32>, | ||
| 531 | +} | ||
| 532 | + | ||
| 533 | +/// Options for [`grid_cols_with`] / the page shell grid. | ||
| 534 | +#[derive(Debug, Clone, Copy)] | ||
| 535 | +pub struct GridOpts { | ||
| 536 | + /// Zebra striping (tables on; page shell off). | ||
| 537 | + pub striped: bool, | ||
| 538 | + /// Column gap (x) and row gap (y). | ||
| 539 | + pub spacing: Vec2, | ||
| 540 | +} | ||
| 541 | + | ||
| 542 | +impl GridOpts { | ||
| 543 | + /// Defaults for nested data tables / multi-column surfaces. | ||
| 544 | + pub fn table(theme: &Theme) -> Self { | ||
| 545 | + Self { | ||
| 546 | + striped: true, | ||
| 547 | + spacing: Vec2::new(theme.spacing.md, 2.0), | ||
| 548 | + } | ||
| 549 | + } | ||
| 550 | + | ||
| 551 | + /// Defaults for top-level [`page_body`] (no striping, section-sized row gap). | ||
| 552 | + pub fn page(theme: &Theme) -> Self { | ||
| 553 | + Self { | ||
| 554 | + striped: false, | ||
| 555 | + spacing: Vec2::new(theme.spacing.md, theme.spacing.lg), | ||
| 556 | + } | ||
| 557 | + } | ||
| 558 | +} | ||
| 559 | + | ||
| 560 | +/// Grid with explicit column specs (recommended for metric tables). | ||
| 561 | +/// | ||
| 562 | +/// The grid container is pinned to `ui.available_width()` so it cannot grow | ||
| 563 | +/// past the parent / viewport residual. Column max widths are distributed via | ||
| 564 | +/// [`distribute_col_max`]. | ||
| 565 | +pub fn grid_cols( | ||
| 566 | + ui: &mut Ui, | ||
| 567 | + theme: &Theme, | ||
| 568 | + id: impl Hash, | ||
| 569 | + cols: &[ColSpec], | ||
| 570 | + add: impl FnOnce(&mut GridCtx<'_, '_>), | ||
| 571 | +) { | ||
| 572 | + grid_cols_with(ui, theme, id, cols, GridOpts::table(theme), add); | ||
| 573 | +} | ||
| 574 | + | ||
| 575 | +/// Grid with explicit column specs and layout options. | ||
| 576 | +pub fn grid_cols_with( | ||
| 577 | + ui: &mut Ui, | ||
| 578 | + theme: &Theme, | ||
| 579 | + id: impl Hash, | ||
| 580 | + cols: &[ColSpec], | ||
| 581 | + opts: GridOpts, | ||
| 582 | + add: impl FnOnce(&mut GridCtx<'_, '_>), | ||
| 583 | +) { | ||
| 584 | + let avail = ui.available_width().max(1.0); | ||
| 585 | + let n = if cols.is_empty() { 16 } else { cols.len() }; | ||
| 586 | + let col_widths: Vec<Option<f32>> = cols.iter().map(|c| c.px(theme)).collect(); | ||
| 587 | + let spacing = opts.spacing; | ||
| 588 | + let col_max = if cols.is_empty() { | ||
| 589 | + distribute_col_max(&vec![None; n], avail, spacing.x) | ||
| 590 | + } else { | ||
| 591 | + distribute_col_max(&col_widths, avail, spacing.x) | ||
| 592 | + }; | ||
| 593 | + let cell_max = col_max.iter().copied().fold(24.0_f32, f32::max); | ||
| 594 | + | ||
| 595 | + // Pin container to residual width (no early clip_rect — that can zero out | ||
| 596 | + // height before layout and blank the whole page). | ||
| 597 | + ui.scope(|ui| { | ||
| 598 | + ui.set_max_width(avail); | ||
| 599 | + Grid::new(id) | ||
| 600 | + .num_columns(n) | ||
| 601 | + .spacing(spacing) | ||
| 602 | + .min_col_width(24.0) | ||
| 603 | + .max_col_width(cell_max) | ||
| 604 | + .striped(opts.striped) | ||
| 605 | + .show(ui, |ui| { | ||
| 606 | + ui.set_max_width(avail); | ||
| 607 | + let mut ctx = GridCtx { | ||
| 608 | + ui, | ||
| 609 | + theme, | ||
| 610 | + col_widths, | ||
| 611 | + col_max, | ||
| 612 | + }; | ||
| 613 | + add(&mut ctx); | ||
| 614 | + }); | ||
| 615 | + }); | ||
| 616 | +} | ||
| 617 | + | ||
| 618 | +/// Grid with all-flex columns. Prefer [`grid_cols`] when you have metrics. | ||
| 619 | +pub fn grid(ui: &mut Ui, theme: &Theme, id: impl Hash, add: impl FnOnce(&mut GridCtx<'_, '_>)) { | ||
| 620 | + grid_cols(ui, theme, id, &[], add); | ||
| 621 | +} | ||
| 622 | + | ||
| 623 | +impl<'ui, 'th> GridCtx<'ui, 'th> { | ||
| 624 | + /// Full-width page section: one row containing one cell. | ||
| 625 | + /// | ||
| 626 | + /// Primary building block for [`page_body`] / [`central_page`]. Put nested | ||
| 627 | + /// grids, cards, and free-form widgets **inside** the section — not as | ||
| 628 | + /// siblings of the page grid. | ||
| 629 | + pub fn section(&mut self, add: impl FnOnce(&mut Ui)) { | ||
| 630 | + self.row(|r| { | ||
| 631 | + r.cell(add); | ||
| 632 | + }); | ||
| 633 | + } | ||
| 634 | + | ||
| 635 | + /// One table row. Cells are written left→right; `end_row` is automatic. | ||
| 636 | + pub fn row(&mut self, add: impl FnOnce(&mut RowDsl<'_, 'th>)) { | ||
| 637 | + let mut col_i = 0usize; | ||
| 638 | + let mut row = RowDsl { | ||
| 639 | + ui: self.ui, | ||
| 640 | + theme: self.theme, | ||
| 641 | + col_widths: &self.col_widths, | ||
| 642 | + col_max: &self.col_max, | ||
| 643 | + col_i: &mut col_i, | ||
| 644 | + }; | ||
| 645 | + add(&mut row); | ||
| 646 | + self.ui.end_row(); | ||
| 647 | + } | ||
| 648 | + | ||
| 649 | + /// Access the underlying grid `Ui` (escape hatch). | ||
| 650 | + pub fn ui(&mut self) -> &mut Ui { | ||
| 651 | + self.ui | ||
| 652 | + } | ||
| 653 | + | ||
| 654 | + pub fn theme(&self) -> &Theme { | ||
| 655 | + self.theme | ||
| 656 | + } | ||
| 657 | +} | ||
| 658 | + | ||
| 659 | +/// One row inside [`GridCtx::row`]. | ||
| 660 | +pub struct RowDsl<'ui, 'th> { | ||
| 661 | + ui: &'ui mut Ui, | ||
| 662 | + theme: &'th Theme, | ||
| 663 | + col_widths: &'ui [Option<f32>], | ||
| 664 | + col_max: &'ui [f32], | ||
| 665 | + col_i: &'ui mut usize, | ||
| 666 | +} | ||
| 667 | + | ||
| 668 | +impl<'ui, 'th> RowDsl<'ui, 'th> { | ||
| 669 | + fn advance(&mut self) { | ||
| 670 | + *self.col_i += 1; | ||
| 671 | + } | ||
| 672 | + | ||
| 673 | + fn width_hint(&self) -> Option<f32> { | ||
| 674 | + self.col_widths.get(*self.col_i).copied().flatten() | ||
| 675 | + } | ||
| 676 | + | ||
| 677 | + /// Hard max for this column (viewport residual budget). | ||
| 678 | + fn col_max(&self) -> f32 { | ||
| 679 | + self.col_max | ||
| 680 | + .get(*self.col_i) | ||
| 681 | + .copied() | ||
| 682 | + .unwrap_or(self.ui.available_width().max(1.0)) | ||
| 683 | + } | ||
| 684 | + | ||
| 685 | + fn metric_width(&self) -> f32 { | ||
| 686 | + let floor = self | ||
| 687 | + .width_hint() | ||
| 688 | + .unwrap_or_else(|| metric_cell_px(self.theme, METRIC_BPS_CHARS)); | ||
| 689 | + floor.min(self.col_max()).max(1.0) | ||
| 690 | + } | ||
| 691 | + | ||
| 692 | + /// Free-form cell, sized to the column max width (top-aligned in the row). | ||
| 693 | + pub fn cell(&mut self, add: impl FnOnce(&mut Ui)) { | ||
| 694 | + let max_w = self.col_max().max(1.0); | ||
| 695 | + // Width-capped, top-down group = one grid cell. | ||
| 696 | + // | ||
| 697 | + // Do **not** use `allocate_ui_with_layout(Vec2::new(max_w, 0.0), …)`. | ||
| 698 | + // egui::Grid places desired sizes with `Align2::LEFT_CENTER`, so a | ||
| 699 | + // zero-height seed is parked mid-row and content grows downward — | ||
| 700 | + // leaving a large empty band above (page sections look vertically | ||
| 701 | + // centered in the panel). `with_layout` uses the cell's available | ||
| 702 | + // rect from the top of the row instead. | ||
| 703 | + self.ui.with_layout(Layout::top_down(Align::Min), |ui| { | ||
| 704 | + ui.set_min_width(max_w); | ||
| 705 | + ui.set_max_width(max_w); | ||
| 706 | + add(ui); | ||
| 707 | + }); | ||
| 708 | + self.advance(); | ||
| 709 | + } | ||
| 710 | + | ||
| 711 | + /// Column header (caption, strong, secondary). Capped to column max. | ||
| 712 | + pub fn heading(&mut self, text: &str) { | ||
| 713 | + let size = self.theme.type_scale.caption; | ||
| 714 | + let max_w = self.col_max(); | ||
| 715 | + let rt = RichText::new(text) | ||
| 716 | + .size(size) | ||
| 717 | + .strong() | ||
| 718 | + .color(self.theme.palette.text_secondary); | ||
| 719 | + let need = measure_text(self.ui, text, size, false) + self.theme.spacing.sm; | ||
| 720 | + let width = if let Some(hint) = self.width_hint() { | ||
| 721 | + hint.max(need).min(max_w) | ||
| 722 | + } else { | ||
| 723 | + need.min(max_w) | ||
| 724 | + }; | ||
| 725 | + self.ui.allocate_ui_with_layout( | ||
| 726 | + Vec2::new(width.max(1.0), size + 6.0), | ||
| 727 | + Layout::right_to_left(Align::Center), | ||
| 728 | + |ui| { | ||
| 729 | + ui.set_max_width(width.max(1.0)); | ||
| 730 | + ui.add(egui::Label::new(rt).truncate()); | ||
| 731 | + }, | ||
| 732 | + ); | ||
| 733 | + self.advance(); | ||
| 734 | + } | ||
| 735 | + | ||
| 736 | + /// Primary body text (flex) — truncates within column max. | ||
| 737 | + pub fn text(&mut self, text: &str) { | ||
| 738 | + table_text_capped(self.ui, self.theme, text, true, self.col_max()); | ||
| 739 | + self.advance(); | ||
| 740 | + } | ||
| 741 | + | ||
| 742 | + /// Secondary caption text (flex) — truncates within column max. | ||
| 743 | + pub fn dim(&mut self, text: &str) { | ||
| 744 | + table_text_capped(self.ui, self.theme, text, false, self.col_max()); | ||
| 745 | + self.advance(); | ||
| 746 | + } | ||
| 747 | + | ||
| 748 | + /// Warning-colored strong caption (e.g. anomaly process name). | ||
| 749 | + pub fn warn(&mut self, text: &str) { | ||
| 750 | + let max_w = self.col_max(); | ||
| 751 | + let rt = RichText::new(text) | ||
| 752 | + .size(self.theme.type_scale.caption) | ||
| 753 | + .strong() | ||
| 754 | + .color(self.theme.palette.warning); | ||
| 755 | + self.ui.scope(|ui| { | ||
| 756 | + ui.set_max_width(max_w); | ||
| 757 | + ui.add(egui::Label::new(rt).truncate()); | ||
| 758 | + }); | ||
| 759 | + self.advance(); | ||
| 760 | + } | ||
| 761 | + | ||
| 762 | + /// Right-aligned monospace metric (`text` from [`metric_bps`] / [`metric_rate`]). | ||
| 763 | + pub fn metric(&mut self, text: &str) { | ||
| 764 | + metric_cell(self.ui, self.theme, self.metric_width(), text, false); | ||
| 765 | + self.advance(); | ||
| 766 | + } | ||
| 767 | + | ||
| 768 | + /// Secondary (dim) metric. | ||
| 769 | + pub fn metric_dim(&mut self, text: &str) { | ||
| 770 | + metric_cell(self.ui, self.theme, self.metric_width(), text, true); | ||
| 771 | + self.advance(); | ||
| 772 | + } | ||
| 773 | + | ||
| 774 | + /// Throughput from raw B/s. | ||
| 775 | + pub fn metric_bps(&mut self, bps: f64) { | ||
| 776 | + let w = self | ||
| 777 | + .width_hint() | ||
| 778 | + .unwrap_or_else(|| metric_cell_px(self.theme, METRIC_BPS_CHARS)) | ||
| 779 | + .min(self.col_max()) | ||
| 780 | + .max(1.0); | ||
| 781 | + metric_cell(self.ui, self.theme, w, &metric_bps(bps), false); | ||
| 782 | + self.advance(); | ||
| 783 | + } | ||
| 784 | + | ||
| 785 | + /// Event rate from raw 1/s. | ||
| 786 | + pub fn metric_rate(&mut self, rate: f64) { | ||
| 787 | + let w = self | ||
| 788 | + .width_hint() | ||
| 789 | + .unwrap_or_else(|| metric_cell_px(self.theme, METRIC_RATE_CHARS)) | ||
| 790 | + .min(self.col_max()) | ||
| 791 | + .max(1.0); | ||
| 792 | + metric_cell(self.ui, self.theme, w, &metric_rate(rate), true); | ||
| 793 | + self.advance(); | ||
| 794 | + } | ||
| 795 | +} | ||
| 796 | + | ||
| 797 | +// ── Metrics / tables ──────────────────────────────────────────────────────── | ||
| 798 | + | ||
| 799 | +/// Measure laid-out width of text (no wrap). | ||
| 800 | +fn measure_text(ui: &Ui, text: &str, size: f32, mono: bool) -> f32 { | ||
| 801 | + let family = if mono { | ||
| 802 | + egui::FontFamily::Monospace | ||
| 803 | + } else { | ||
| 804 | + egui::FontFamily::Proportional | ||
| 805 | + }; | ||
| 806 | + let font = FontId::new(size, family); | ||
| 807 | + ui.fonts(|f| { | ||
| 808 | + f.layout_no_wrap(text.to_owned(), font, egui::Color32::WHITE) | ||
| 809 | + .size() | ||
| 810 | + .x | ||
| 811 | + }) | ||
| 812 | +} | ||
| 813 | + | ||
| 814 | +/// Measure monospace caption width for `text`. | ||
| 815 | +pub fn measure_mono_caption(ui: &Ui, theme: &Theme, text: &str) -> f32 { | ||
| 816 | + measure_text(ui, text, theme.type_scale.caption, true) | ||
| 817 | +} | ||
| 818 | + | ||
| 819 | +/// Paint a monospace metric string, right-edge aligned. | ||
| 820 | +/// | ||
| 821 | +/// `min_width` is the cell size (from ColSpec floor, clamped by | ||
| 822 | +/// [`distribute_col_max`] to the residual viewport). Text is clip-rect'd so it | ||
| 823 | +/// never paints past the cell. | ||
| 824 | +pub fn metric_cell(ui: &mut Ui, theme: &Theme, min_width: f32, text: &str, secondary: bool) { | ||
| 825 | + let width = min_width.max(1.0); | ||
| 826 | + let h = theme.type_scale.caption + 8.0; | ||
| 827 | + let (rect, _) = ui.allocate_exact_size(Vec2::new(width, h), Sense::hover()); | ||
| 828 | + if !ui.is_rect_visible(rect) { | ||
| 829 | + return; | ||
| 830 | + } | ||
| 831 | + let color = if secondary { | ||
| 832 | + theme.palette.text_secondary | ||
| 833 | + } else { | ||
| 834 | + theme.palette.text | ||
| 835 | + }; | ||
| 836 | + let font = FontId::monospace(theme.type_scale.caption); | ||
| 837 | + let painter = ui.painter().with_clip_rect(rect); | ||
| 838 | + let pos = egui::pos2(rect.right() - theme.spacing.xs.max(2.0), rect.center().y); | ||
| 839 | + painter.text(pos, egui::Align2::RIGHT_CENTER, text, font, color); | ||
| 840 | +} | ||
| 841 | + | ||
| 842 | +/// Column kind for [`data_table`] (thin table helper over the grid DSL). | ||
| 843 | +#[derive(Debug, Clone, Copy)] | ||
| 844 | +pub enum ColKind { | ||
| 845 | + Flex, | ||
| 846 | + Metric { width: f32 }, | ||
| 847 | +} | ||
| 848 | + | ||
| 849 | +/// One column header + kind for [`data_table`]. | ||
| 850 | +#[derive(Debug, Clone, Copy)] | ||
| 851 | +pub struct Col { | ||
| 852 | + pub header: &'static str, | ||
| 853 | + pub kind: ColKind, | ||
| 854 | +} | ||
| 855 | + | ||
| 856 | +/// Striped data table built on [`grid_cols`]. | ||
| 857 | +/// | ||
| 858 | +/// Prefer the row DSL (`grid_cols` + `g.row`) for new code; this keeps the | ||
| 859 | +/// index-callback shape used by existing consumers. | ||
| 860 | +pub fn data_table( | ||
| 861 | + ui: &mut Ui, | ||
| 862 | + theme: &Theme, | ||
| 863 | + id: impl Hash, | ||
| 864 | + columns: &[Col], | ||
| 865 | + mut row: impl FnMut(&mut Ui, usize), | ||
| 866 | + row_count: usize, | ||
| 867 | +) { | ||
| 868 | + let specs: Vec<ColSpec> = columns | ||
| 869 | + .iter() | ||
| 870 | + .map(|c| match c.kind { | ||
| 871 | + ColKind::Flex => ColSpec::Flex, | ||
| 872 | + ColKind::Metric { width } => ColSpec::Fixed(width), | ||
| 873 | + }) | ||
| 874 | + .collect(); | ||
| 875 | + | ||
| 876 | + // Same viewport pin + col-max policy as [`grid_cols`]. | ||
| 877 | + let avail = ui.available_width().max(1.0); | ||
| 878 | + let n = columns.len().max(1); | ||
| 879 | + let col_widths: Vec<Option<f32>> = specs.iter().map(|c| c.px(theme)).collect(); | ||
| 880 | + let spacing = Vec2::new(theme.spacing.md, 2.0); | ||
| 881 | + let col_max = distribute_col_max(&col_widths, avail, spacing.x); | ||
| 882 | + let cell_max = col_max.iter().copied().fold(40.0_f32, f32::max); | ||
| 883 | + | ||
| 884 | + ui.scope(|ui| { | ||
| 885 | + ui.set_max_width(avail); | ||
| 886 | + Grid::new(id) | ||
| 887 | + .num_columns(n) | ||
| 888 | + .spacing(spacing) | ||
| 889 | + .min_col_width(24.0) | ||
| 890 | + .max_col_width(cell_max) | ||
| 891 | + .striped(true) | ||
| 892 | + .show(ui, |ui| { | ||
| 893 | + ui.set_max_width(avail); | ||
| 894 | + let mut col_i = 0usize; | ||
| 895 | + { | ||
| 896 | + let mut r = RowDsl { | ||
| 897 | + ui, | ||
| 898 | + theme, | ||
| 899 | + col_widths: &col_widths, | ||
| 900 | + col_max: &col_max, | ||
| 901 | + col_i: &mut col_i, | ||
| 902 | + }; | ||
| 903 | + for col in columns { | ||
| 904 | + r.heading(col.header); | ||
| 905 | + } | ||
| 906 | + } | ||
| 907 | + ui.end_row(); | ||
| 908 | + for i in 0..row_count { | ||
| 909 | + row(ui, i); | ||
| 910 | + ui.end_row(); | ||
| 911 | + } | ||
| 912 | + }); | ||
| 913 | + }); | ||
| 914 | +} | ||
| 915 | + | ||
| 916 | +/// Flex text cell for [`data_table`] rows / grid rows (truncate to available). | ||
| 917 | +pub fn table_text(ui: &mut Ui, theme: &Theme, text: &str, primary: bool) { | ||
| 918 | + table_text_capped(ui, theme, text, primary, ui.available_width()); | ||
| 919 | +} | ||
| 920 | + | ||
| 921 | +/// Flex text capped to `max_w` so grid columns stay within the viewport budget. | ||
| 922 | +pub fn table_text_capped(ui: &mut Ui, theme: &Theme, text: &str, primary: bool, max_w: f32) { | ||
| 923 | + let color = if primary { | ||
| 924 | + theme.palette.text | ||
| 925 | + } else { | ||
| 926 | + theme.palette.text_secondary | ||
| 927 | + }; | ||
| 928 | + ui.scope(|ui| { | ||
| 929 | + ui.set_max_width(max_w.max(1.0)); | ||
| 930 | + ui.add( | ||
| 931 | + egui::Label::new( | ||
| 932 | + RichText::new(text) | ||
| 933 | + .size(if primary { | ||
| 934 | + theme.type_scale.body | ||
| 935 | + } else { | ||
| 936 | + theme.type_scale.caption | ||
| 937 | + }) | ||
| 938 | + .color(color), | ||
| 939 | + ) | ||
| 940 | + .truncate(), | ||
| 941 | + ); | ||
| 942 | + }); | ||
| 943 | +} | ||
| 944 | + | ||
| 945 | +/// Metric cell for [`data_table`] rows (`text` should be [`metric_bps`] / [`metric_rate`]). | ||
| 946 | +pub fn table_metric(ui: &mut Ui, theme: &Theme, width: f32, text: &str, secondary: bool) { | ||
| 947 | + metric_cell(ui, theme, width, text, secondary); | ||
| 948 | +} | ||
| 949 | + | ||
| 950 | +// ── Tests ─────────────────────────────────────────────────────────────────── | ||
| 951 | + | ||
| 952 | +#[cfg(test)] | ||
| 953 | +mod tests { | ||
| 954 | + use super::*; | ||
| 955 | + use crate::Theme; | ||
| 956 | + | ||
| 957 | + #[test] | ||
| 958 | + fn side_by_side_policy_matches_two_col_breakpoint() { | ||
| 959 | + let gap = 12.0; | ||
| 960 | + let min = 160.0; | ||
| 961 | + assert!(!side_by_side(100.0, min, gap)); | ||
| 962 | + assert!(!side_by_side(min * 2.0 + gap - 1.0, min, gap)); | ||
| 963 | + assert!(side_by_side(min * 2.0 + gap, min, gap)); | ||
| 964 | + assert!(side_by_side(800.0, min, gap)); | ||
| 965 | + assert!(!side_by_side(800.0, 0.0, gap)); | ||
| 966 | + } | ||
| 967 | + | ||
| 968 | + #[test] | ||
| 969 | + fn metric_bps_fixed_char_width() { | ||
| 970 | + for bps in [0.0, 100.0, 44.8 * 1024.0, 2.0 * 1024.0 * 1024.0, 999.0] { | ||
| 971 | + let s = metric_bps(bps); | ||
| 972 | + assert_eq!( | ||
| 973 | + s.chars().count(), | ||
| 974 | + METRIC_BPS_CHARS, | ||
| 975 | + "metric_bps({bps}) = {s:?}" | ||
| 976 | + ); | ||
| 977 | + assert!( | ||
| 978 | + s.ends_with(format_bps(bps).as_str()), | ||
| 979 | + "padding must preserve value" | ||
| 980 | + ); | ||
| 981 | + } | ||
| 982 | + } | ||
| 983 | + | ||
| 984 | + #[test] | ||
| 985 | + fn metric_rate_fixed_char_width() { | ||
| 986 | + for rate in [0.0, 1.5, 134.0, 2600.0, 1_500_000.0] { | ||
| 987 | + let s = metric_rate(rate); | ||
| 988 | + assert_eq!( | ||
| 989 | + s.chars().count(), | ||
| 990 | + METRIC_RATE_CHARS, | ||
| 991 | + "metric_rate({rate}) = {s:?}" | ||
| 992 | + ); | ||
| 993 | + assert!( | ||
| 994 | + s.contains(format_rate(rate).as_str()) || s.ends_with(format_rate(rate).as_str()) | ||
| 995 | + ); | ||
| 996 | + } | ||
| 997 | + } | ||
| 998 | + | ||
| 999 | + #[test] | ||
| 1000 | + fn pad_metric_is_identity_when_already_wide() { | ||
| 1001 | + let long = "123456789012345"; // 15 > 14 | ||
| 1002 | + assert_eq!(pad_metric(long, METRIC_BPS_CHARS), long); | ||
| 1003 | + } | ||
| 1004 | + | ||
| 1005 | + #[test] | ||
| 1006 | + fn metric_cell_px_scales_with_theme_caption() { | ||
| 1007 | + let th = Theme::dark(); | ||
| 1008 | + let a = metric_cell_px(&th, METRIC_BPS_CHARS); | ||
| 1009 | + let b = metric_cell_px(&th, METRIC_RATE_CHARS); | ||
| 1010 | + assert!(a > b); | ||
| 1011 | + assert!(a > 40.0); | ||
| 1012 | + // Floor must cover full padded glyph run (chars × 0.72em + pad). | ||
| 1013 | + let floor = th.type_scale.caption * 0.72 * METRIC_BPS_CHARS as f32; | ||
| 1014 | + assert!( | ||
| 1015 | + a >= floor, | ||
| 1016 | + "metric_cell_px={a} must be >= glyph floor {floor}" | ||
| 1017 | + ); | ||
| 1018 | + } | ||
| 1019 | + | ||
| 1020 | + #[test] | ||
| 1021 | + fn metric_bps_string_never_exceeds_char_budget() { | ||
| 1022 | + // Ensures the padded formatter and char budget stay in sync so | ||
| 1023 | + // metric_cell_px floors remain meaningful. | ||
| 1024 | + for bps in [0.0, 1.0, 512.0, 1024.0 * 50.0, 1024.0 * 1024.0 * 9.9] { | ||
| 1025 | + let s = metric_bps(bps); | ||
| 1026 | + assert!( | ||
| 1027 | + s.chars().count() <= METRIC_BPS_CHARS || s.chars().count() == METRIC_BPS_CHARS, | ||
| 1028 | + "unexpected width for {s:?}" | ||
| 1029 | + ); | ||
| 1030 | + assert_eq!(s.chars().count(), METRIC_BPS_CHARS); | ||
| 1031 | + } | ||
| 1032 | + } | ||
| 1033 | + | ||
| 1034 | + #[test] | ||
| 1035 | + fn default_min_col_positive() { | ||
| 1036 | + let th = Theme::dark(); | ||
| 1037 | + assert!(default_min_col(&th) > 100.0); | ||
| 1038 | + } | ||
| 1039 | + | ||
| 1040 | + #[test] | ||
| 1041 | + fn col_spec_resolves_metric_widths() { | ||
| 1042 | + let th = Theme::dark(); | ||
| 1043 | + assert!(ColSpec::Flex.px(&th).is_none()); | ||
| 1044 | + assert_eq!(ColSpec::Fixed(120.0).px(&th), Some(120.0)); | ||
| 1045 | + let bps = ColSpec::MetricBps.px(&th).unwrap(); | ||
| 1046 | + let rate = ColSpec::MetricRate.px(&th).unwrap(); | ||
| 1047 | + assert!(bps > rate); | ||
| 1048 | + assert!((bps - metric_cell_px(&th, METRIC_BPS_CHARS)).abs() < 0.01); | ||
| 1049 | + } | ||
| 1050 | + | ||
| 1051 | + #[test] | ||
| 1052 | + fn grid_opts_page_is_not_striped_and_uses_lg_row_gap() { | ||
| 1053 | + let th = Theme::dark(); | ||
| 1054 | + let page = GridOpts::page(&th); | ||
| 1055 | + let table = GridOpts::table(&th); | ||
| 1056 | + assert!(!page.striped); | ||
| 1057 | + assert!(table.striped); | ||
| 1058 | + assert!((page.spacing.y - th.spacing.lg).abs() < 0.01); | ||
| 1059 | + assert!((table.spacing.y - 2.0).abs() < 0.01); | ||
| 1060 | + } | ||
| 1061 | + | ||
| 1062 | + #[test] | ||
| 1063 | + fn page_shell_api_is_grid_only() { | ||
| 1064 | + // Source-level contract: page_body / central_page take GridCtx, not free Ui. | ||
| 1065 | + let layout = include_str!("layout.rs"); | ||
| 1066 | + // Signature block for page_body must include theme + GridCtx (not free Ui only). | ||
| 1067 | + let start = layout | ||
| 1068 | + .find("pub fn page_body(\n") | ||
| 1069 | + .expect("page_body definition"); | ||
| 1070 | + let sig = &layout[start..start + 280]; | ||
| 1071 | + assert!( | ||
| 1072 | + sig.contains("theme: &Theme"), | ||
| 1073 | + "page_body must take theme for grid" | ||
| 1074 | + ); | ||
| 1075 | + assert!( | ||
| 1076 | + sig.contains("GridCtx"), | ||
| 1077 | + "page_body must take GridCtx callback: {sig}" | ||
| 1078 | + ); | ||
| 1079 | + assert!( | ||
| 1080 | + !sig.contains("FnOnce(&mut Ui)"), | ||
| 1081 | + "page_body must not accept free-form Ui: {sig}" | ||
| 1082 | + ); | ||
| 1083 | + assert!( | ||
| 1084 | + layout.contains("page_body(ui, theme, id, add)"), | ||
| 1085 | + "central_page must route through grid page_body" | ||
| 1086 | + ); | ||
| 1087 | + assert!( | ||
| 1088 | + layout.contains("/// Full-width page section"), | ||
| 1089 | + "GridCtx::section is the preferred page building block" | ||
| 1090 | + ); | ||
| 1091 | + // Escape hatch exists but is not the app path. | ||
| 1092 | + assert!(layout.contains("pub fn page_scroll(")); | ||
| 1093 | + } | ||
| 1094 | + | ||
| 1095 | + #[test] | ||
| 1096 | + fn distribute_col_max_never_exceeds_budget() { | ||
| 1097 | + let gap = 12.0; | ||
| 1098 | + let specs = vec![None, None, Some(100.0), Some(80.0)]; | ||
| 1099 | + for avail in [200.0_f32, 400.0, 800.0, 100.0] { | ||
| 1100 | + let maxes = distribute_col_max(&specs, avail, gap); | ||
| 1101 | + let gaps = gap * (specs.len() - 1) as f32; | ||
| 1102 | + let sum: f32 = maxes.iter().sum(); | ||
| 1103 | + assert!( | ||
| 1104 | + sum + gaps <= avail + 0.5, | ||
| 1105 | + "sum={sum} gaps={gaps} avail={avail} maxes={maxes:?}" | ||
| 1106 | + ); | ||
| 1107 | + assert_eq!(maxes.len(), specs.len()); | ||
| 1108 | + } | ||
| 1109 | + } | ||
| 1110 | + | ||
| 1111 | + #[test] | ||
| 1112 | + fn distribute_col_max_scales_fixed_when_tight() { | ||
| 1113 | + let specs = vec![Some(200.0), Some(200.0)]; | ||
| 1114 | + let maxes = distribute_col_max(&specs, 200.0, 0.0); | ||
| 1115 | + assert!((maxes[0] + maxes[1] - 200.0).abs() < 0.01); | ||
| 1116 | + assert!(maxes[0] < 200.0); | ||
| 1117 | + } | ||
| 1118 | + | ||
| 1119 | + #[test] | ||
| 1120 | + fn distribute_all_flex_equal() { | ||
| 1121 | + let specs = vec![None, None, None, None]; | ||
| 1122 | + let maxes = distribute_col_max(&specs, 400.0, 0.0); | ||
| 1123 | + for w in &maxes { | ||
| 1124 | + assert!((*w - 100.0).abs() < 0.01); | ||
| 1125 | + } | ||
| 1126 | + } | ||
| 1127 | + | ||
| 1128 | + #[test] | ||
| 1129 | + fn card_frame_chrome_x_is_margins_plus_stroke() { | ||
| 1130 | + let th = Theme::dark(); | ||
| 1131 | + // md*2 + 1px stroke each side | ||
| 1132 | + assert!((card_frame_chrome_x(&th) - (th.spacing.md * 2.0 + 2.0)).abs() < 0.01); | ||
| 1133 | + assert!(card_frame_chrome_x(&th) > th.spacing.md); | ||
| 1134 | + } | ||
| 1135 | + | ||
| 1136 | + #[test] | ||
| 1137 | + fn four_flex_cols_fit_viewport_budget() { | ||
| 1138 | + // Same shape as the usage gauge row. | ||
| 1139 | + let gap = 12.0; | ||
| 1140 | + let avail = 700.0; | ||
| 1141 | + let specs = vec![None, None, None, None]; | ||
| 1142 | + let maxes = distribute_col_max(&specs, avail, gap); | ||
| 1143 | + let sum: f32 = maxes.iter().sum::<f32>() + gap * 3.0; | ||
| 1144 | + assert!(sum <= avail + 0.01, "sum={sum} avail={avail}"); | ||
| 1145 | + for w in maxes { | ||
| 1146 | + assert!(w > 50.0); | ||
| 1147 | + } | ||
| 1148 | + } | ||
| 1149 | +} | ||
added
crates/vidya-core/src/lib.rs +133 -0 | new file mode 100644 | ||
| @@ -0,0 +1,133 @@ | ||
| 1 | +//! **Vidya** — theme layer on top of [egui]. | |
| 2 | +//! | |
| 3 | +//! Palette and spacing track GNOME HIG / libadwaita *feel* without linking GTK | |
| 4 | +//! or using that name in the public API. Dark is the default. | |
| 5 | +//! | |
| 6 | +//! On Android (edge-to-edge), call [`reserve_system_chrome`] once per frame | |
| 7 | +//! **before** other panels, or use [`top_header`] so content cannot sit under | |
| 8 | +//! the system status / navigation bars. | |
| 9 | +//! | |
| 10 | +//! Layout composition: prefer [`page_body`] / [`card`] / [`compact_card`] / | |
| 11 | +//! [`pack`] / [`vstack`] / [`lead_trail`] / [`two_col`] / [`grid_cols`] / | |
| 12 | +//! [`metric_bps`] so apps avoid width overflow, justified-gap stretch, and | |
| 13 | +//! metric “waterfall” columns. | |
| 14 | +//! | |
| 15 | +//! **Top-level application UI is grid-only:** [`central_page`] / [`page_body`] | |
| 16 | +//! take a [`GridCtx`] callback (not a free-stack `Ui`). Compose with | |
| 17 | +//! [`GridCtx::section`] / `g.row`; nest cards and nested grids inside cells. | |
| 18 | +//! | |
| 19 | +//! ```ignore | |
| 20 | +//! vidya::central_page(ctx, &th, "main", |g| { | |
| 21 | +//! g.section(|ui| { /* … */ }); | |
| 22 | +//! g.section(|ui| { /* … */ }); | |
| 23 | +//! }); | |
| 24 | +//! ``` | |
| 25 | +//! | |
| 26 | +//! Nested / table Grid DSL: | |
| 27 | +//! ```ignore | |
| 28 | +//! vidya::grid_cols(ui, &th, "procs", &[ColSpec::Flex, ColSpec::MetricBps], |g| { | |
| 29 | +//! g.row(|r| { r.heading("Name"); r.heading("Write"); }); | |
| 30 | +//! g.row(|r| { r.text(name); r.metric_bps(rate); }); | |
| 31 | +//! }); | |
| 32 | +//! ``` | |
| 33 | +//! | |
| 34 | +//! Window icon (embed a PNG with `include_bytes!`): | |
| 35 | +//! ```ignore | |
| 36 | +//! use egui::ViewportBuilder; | |
| 37 | +//! use vidya::with_app_icon_id; | |
| 38 | +//! | |
| 39 | +//! let viewport = with_app_icon_id( | |
| 40 | +//! ViewportBuilder::default().with_title("usage"), | |
| 41 | +//! "usage", // Wayland app_id ↔ .desktop StartupWMClass | |
| 42 | +//! include_bytes!("../assets/usage-256.png"), | |
| 43 | +//! ); | |
| 44 | +//! ``` | |
| 45 | +//! | |
| 46 | +//! ```ignore | |
| 47 | +//! use vidya::{apply_dark, Theme}; | |
| 48 | +//! | |
| 49 | +//! apply_dark(ctx); | |
| 50 | +//! if vidya::primary_button(ui, &Theme::dark(), "Open").clicked() { /* … */ } | |
| 51 | +//! ``` | |
| 52 | + | |
| 53 | +mod app_icon; | |
| 54 | +mod chrome; | |
| 55 | +mod fonts; | |
| 56 | +mod hotkeys; | |
| 57 | +mod icons; | |
| 58 | +mod layout; | |
| 59 | +mod theme; | |
| 60 | +mod video; | |
| 61 | + | |
| 62 | +pub use app_icon::{ | |
| 63 | + icon_data_from_png, try_with_app_icon, try_with_app_icon_id, with_app_icon, with_app_icon_id, | |
| 64 | + AppIconError, | |
| 65 | +}; | |
| 66 | +#[cfg(target_os = "android")] | |
| 67 | +pub use chrome::sync_system_chrome_from_android; | |
| 68 | +pub use chrome::{ | |
| 69 | + dialog, reserve_system_chrome, set_system_chrome, system_chrome, top_header, SystemChrome, | |
| 70 | +}; | |
| 71 | +pub use fonts::install_symbol_font; | |
| 72 | +pub use hotkeys::{ | |
| 73 | + command_glyph, command_pressed, command_shift_shortcut_label, command_shortcut_label, | |
| 74 | + consume_command, consume_command_shift, consume_escape, escape_label, is_macos, | |
| 75 | +}; | |
| 76 | +pub use icons::{ | |
| 77 | + emoji_icon, emoji_icon_colored, emoji_pack_len, has_emoji_icon, icon, icon_colored, | |
| 78 | + icon_for_emoji, normalize_emoji, paint_emoji_in, paint_icon, paint_icon_in, reaction_chip, | |
| 79 | + reaction_chip_sized, twemoji_key, Icon, | |
| 80 | +}; | |
| 81 | +pub use layout::{ | |
| 82 | + card, card_frame_chrome_x, central_page, central_page_cols, compact_card, data_table, | |
| 83 | + default_min_col, distribute_col_max, fill_width, fit_width, format_bps, format_rate, grid, | |
| 84 | + grid_cols, grid_cols_with, hflow, inset_row, lead_trail, measure_mono_caption, metric_bps, | |
| 85 | + metric_cell, metric_cell_px, metric_rate, pack, pad_metric, page_body, page_body_cols, | |
| 86 | + page_scroll, side_by_side, table_metric, table_text, table_text_capped, two_col, vstack, Col, | |
| 87 | + ColKind, ColSpec, GridCtx, GridOpts, RowDsl, FLEX_COL_MIN_PX, METRIC_BPS_CHARS, | |
| 88 | + METRIC_RATE_CHARS, | |
| 89 | +}; | |
| 90 | +pub use theme::{ | |
| 91 | + body, button, checkbox, destructive_button, dim_label, icon_button, primary_button, status_dot, | |
| 92 | + text_field_multiline, text_field_singleline, title, title_2, Mode, Palette, Spacing, Theme, | |
| 93 | + TypeScale, | |
| 94 | +}; | |
| 95 | +pub use video::{video_player, VideoPlayerAction, VideoPlayerOpts, VideoPlayerState}; | |
| 96 | + | |
| 97 | +use egui::{Context, FontFamily, FontId, Style, TextStyle}; | |
| 98 | + | |
| 99 | +/// Install palette + spacing + text styles on the egui context. | |
| 100 | +/// | |
| 101 | +/// Also registers a small symbol-font fallback so punctuation like `→` / `●` / | |
| 102 | +/// `▾` / `▴` and block/box art (`█▄▀░`) do not render as hollow boxes on Android. | |
| 103 | +pub fn apply(ctx: &Context, theme: &Theme) { | |
| 104 | + install_symbol_font(ctx); | |
| 105 | + ctx.set_visuals(theme.visuals()); | |
| 106 | + ctx.set_style(theme.style()); | |
| 107 | +} | |
| 108 | + | |
| 109 | +/// Dark shell (default). | |
| 110 | +pub fn apply_dark(ctx: &Context) { | |
| 111 | + apply(ctx, &Theme::dark()); | |
| 112 | +} | |
| 113 | + | |
| 114 | +/// Light shell. | |
| 115 | +pub fn apply_light(ctx: &Context) { | |
| 116 | + apply(ctx, &Theme::light()); | |
| 117 | +} | |
| 118 | + | |
| 119 | +/// Map type scale onto egui text styles. | |
| 120 | +pub fn install_text_styles(style: &mut Style, scale: &TypeScale) { | |
| 121 | + use FontFamily::Proportional; | |
| 122 | + style.text_styles = [ | |
| 123 | + (TextStyle::Small, FontId::new(scale.caption, Proportional)), | |
| 124 | + (TextStyle::Body, FontId::new(scale.body, Proportional)), | |
| 125 | + (TextStyle::Button, FontId::new(scale.body, Proportional)), | |
| 126 | + (TextStyle::Heading, FontId::new(scale.title, Proportional)), | |
| 127 | + ( | |
| 128 | + TextStyle::Monospace, | |
| 129 | + FontId::new(scale.body, FontFamily::Monospace), | |
| 130 | + ), | |
| 131 | + ] | |
| 132 | + .into(); | |
| 133 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,133 @@ | |||
| 1 | +//! **Vidya** — theme layer on top of [egui]. | ||
| 2 | +//! | ||
| 3 | +//! Palette and spacing track GNOME HIG / libadwaita *feel* without linking GTK | ||
| 4 | +//! or using that name in the public API. Dark is the default. | ||
| 5 | +//! | ||
| 6 | +//! On Android (edge-to-edge), call [`reserve_system_chrome`] once per frame | ||
| 7 | +//! **before** other panels, or use [`top_header`] so content cannot sit under | ||
| 8 | +//! the system status / navigation bars. | ||
| 9 | +//! | ||
| 10 | +//! Layout composition: prefer [`page_body`] / [`card`] / [`compact_card`] / | ||
| 11 | +//! [`pack`] / [`vstack`] / [`lead_trail`] / [`two_col`] / [`grid_cols`] / | ||
| 12 | +//! [`metric_bps`] so apps avoid width overflow, justified-gap stretch, and | ||
| 13 | +//! metric “waterfall” columns. | ||
| 14 | +//! | ||
| 15 | +//! **Top-level application UI is grid-only:** [`central_page`] / [`page_body`] | ||
| 16 | +//! take a [`GridCtx`] callback (not a free-stack `Ui`). Compose with | ||
| 17 | +//! [`GridCtx::section`] / `g.row`; nest cards and nested grids inside cells. | ||
| 18 | +//! | ||
| 19 | +//! ```ignore | ||
| 20 | +//! vidya::central_page(ctx, &th, "main", |g| { | ||
| 21 | +//! g.section(|ui| { /* … */ }); | ||
| 22 | +//! g.section(|ui| { /* … */ }); | ||
| 23 | +//! }); | ||
| 24 | +//! ``` | ||
| 25 | +//! | ||
| 26 | +//! Nested / table Grid DSL: | ||
| 27 | +//! ```ignore | ||
| 28 | +//! vidya::grid_cols(ui, &th, "procs", &[ColSpec::Flex, ColSpec::MetricBps], |g| { | ||
| 29 | +//! g.row(|r| { r.heading("Name"); r.heading("Write"); }); | ||
| 30 | +//! g.row(|r| { r.text(name); r.metric_bps(rate); }); | ||
| 31 | +//! }); | ||
| 32 | +//! ``` | ||
| 33 | +//! | ||
| 34 | +//! Window icon (embed a PNG with `include_bytes!`): | ||
| 35 | +//! ```ignore | ||
| 36 | +//! use egui::ViewportBuilder; | ||
| 37 | +//! use vidya::with_app_icon_id; | ||
| 38 | +//! | ||
| 39 | +//! let viewport = with_app_icon_id( | ||
| 40 | +//! ViewportBuilder::default().with_title("usage"), | ||
| 41 | +//! "usage", // Wayland app_id ↔ .desktop StartupWMClass | ||
| 42 | +//! include_bytes!("../assets/usage-256.png"), | ||
| 43 | +//! ); | ||
| 44 | +//! ``` | ||
| 45 | +//! | ||
| 46 | +//! ```ignore | ||
| 47 | +//! use vidya::{apply_dark, Theme}; | ||
| 48 | +//! | ||
| 49 | +//! apply_dark(ctx); | ||
| 50 | +//! if vidya::primary_button(ui, &Theme::dark(), "Open").clicked() { /* … */ } | ||
| 51 | +//! ``` | ||
| 52 | + | ||
| 53 | +mod app_icon; | ||
| 54 | +mod chrome; | ||
| 55 | +mod fonts; | ||
| 56 | +mod hotkeys; | ||
| 57 | +mod icons; | ||
| 58 | +mod layout; | ||
| 59 | +mod theme; | ||
| 60 | +mod video; | ||
| 61 | + | ||
| 62 | +pub use app_icon::{ | ||
| 63 | + icon_data_from_png, try_with_app_icon, try_with_app_icon_id, with_app_icon, with_app_icon_id, | ||
| 64 | + AppIconError, | ||
| 65 | +}; | ||
| 66 | +#[cfg(target_os = "android")] | ||
| 67 | +pub use chrome::sync_system_chrome_from_android; | ||
| 68 | +pub use chrome::{ | ||
| 69 | + dialog, reserve_system_chrome, set_system_chrome, system_chrome, top_header, SystemChrome, | ||
| 70 | +}; | ||
| 71 | +pub use fonts::install_symbol_font; | ||
| 72 | +pub use hotkeys::{ | ||
| 73 | + command_glyph, command_pressed, command_shift_shortcut_label, command_shortcut_label, | ||
| 74 | + consume_command, consume_command_shift, consume_escape, escape_label, is_macos, | ||
| 75 | +}; | ||
| 76 | +pub use icons::{ | ||
| 77 | + emoji_icon, emoji_icon_colored, emoji_pack_len, has_emoji_icon, icon, icon_colored, | ||
| 78 | + icon_for_emoji, normalize_emoji, paint_emoji_in, paint_icon, paint_icon_in, reaction_chip, | ||
| 79 | + reaction_chip_sized, twemoji_key, Icon, | ||
| 80 | +}; | ||
| 81 | +pub use layout::{ | ||
| 82 | + card, card_frame_chrome_x, central_page, central_page_cols, compact_card, data_table, | ||
| 83 | + default_min_col, distribute_col_max, fill_width, fit_width, format_bps, format_rate, grid, | ||
| 84 | + grid_cols, grid_cols_with, hflow, inset_row, lead_trail, measure_mono_caption, metric_bps, | ||
| 85 | + metric_cell, metric_cell_px, metric_rate, pack, pad_metric, page_body, page_body_cols, | ||
| 86 | + page_scroll, side_by_side, table_metric, table_text, table_text_capped, two_col, vstack, Col, | ||
| 87 | + ColKind, ColSpec, GridCtx, GridOpts, RowDsl, FLEX_COL_MIN_PX, METRIC_BPS_CHARS, | ||
| 88 | + METRIC_RATE_CHARS, | ||
| 89 | +}; | ||
| 90 | +pub use theme::{ | ||
| 91 | + body, button, checkbox, destructive_button, dim_label, icon_button, primary_button, status_dot, | ||
| 92 | + text_field_multiline, text_field_singleline, title, title_2, Mode, Palette, Spacing, Theme, | ||
| 93 | + TypeScale, | ||
| 94 | +}; | ||
| 95 | +pub use video::{video_player, VideoPlayerAction, VideoPlayerOpts, VideoPlayerState}; | ||
| 96 | + | ||
| 97 | +use egui::{Context, FontFamily, FontId, Style, TextStyle}; | ||
| 98 | + | ||
| 99 | +/// Install palette + spacing + text styles on the egui context. | ||
| 100 | +/// | ||
| 101 | +/// Also registers a small symbol-font fallback so punctuation like `→` / `●` / | ||
| 102 | +/// `▾` / `▴` and block/box art (`█▄▀░`) do not render as hollow boxes on Android. | ||
| 103 | +pub fn apply(ctx: &Context, theme: &Theme) { | ||
| 104 | + install_symbol_font(ctx); | ||
| 105 | + ctx.set_visuals(theme.visuals()); | ||
| 106 | + ctx.set_style(theme.style()); | ||
| 107 | +} | ||
| 108 | + | ||
| 109 | +/// Dark shell (default). | ||
| 110 | +pub fn apply_dark(ctx: &Context) { | ||
| 111 | + apply(ctx, &Theme::dark()); | ||
| 112 | +} | ||
| 113 | + | ||
| 114 | +/// Light shell. | ||
| 115 | +pub fn apply_light(ctx: &Context) { | ||
| 116 | + apply(ctx, &Theme::light()); | ||
| 117 | +} | ||
| 118 | + | ||
| 119 | +/// Map type scale onto egui text styles. | ||
| 120 | +pub fn install_text_styles(style: &mut Style, scale: &TypeScale) { | ||
| 121 | + use FontFamily::Proportional; | ||
| 122 | + style.text_styles = [ | ||
| 123 | + (TextStyle::Small, FontId::new(scale.caption, Proportional)), | ||
| 124 | + (TextStyle::Body, FontId::new(scale.body, Proportional)), | ||
| 125 | + (TextStyle::Button, FontId::new(scale.body, Proportional)), | ||
| 126 | + (TextStyle::Heading, FontId::new(scale.title, Proportional)), | ||
| 127 | + ( | ||
| 128 | + TextStyle::Monospace, | ||
| 129 | + FontId::new(scale.body, FontFamily::Monospace), | ||
| 130 | + ), | ||
| 131 | + ] | ||
| 132 | + .into(); | ||
| 133 | +} | ||
added
crates/vidya-core/src/theme.rs +643 -0 | new file mode 100644 | ||
| @@ -0,0 +1,643 @@ | ||
| 1 | +//! Palette, metrics, egui Visuals/Style, and widget helpers. | |
| 2 | + | |
| 3 | +use egui::{ | |
| 4 | + pos2, | |
| 5 | + style::{ScrollStyle, WidgetVisuals}, | |
| 6 | + Button, Color32, CornerRadius, CursorIcon, FontId, Frame, Label, Margin, Response, RichText, | |
| 7 | + Sense, Shadow, Shape, Stroke, StrokeKind, Style, TextBuffer, TextEdit, TextWrapMode, Ui, Vec2, | |
| 8 | + Visuals, WidgetInfo, WidgetType, | |
| 9 | +}; | |
| 10 | + | |
| 11 | +use crate::install_text_styles; | |
| 12 | + | |
| 13 | +/// Light vs dark shell. | |
| 14 | +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] | |
| 15 | +pub enum Mode { | |
| 16 | + #[default] | |
| 17 | + Dark, | |
| 18 | + Light, | |
| 19 | +} | |
| 20 | + | |
| 21 | +/// Semantic colors. | |
| 22 | +#[derive(Debug, Clone)] | |
| 23 | +pub struct Palette { | |
| 24 | + pub window_bg: Color32, | |
| 25 | + pub view_bg: Color32, | |
| 26 | + pub headerbar_bg: Color32, | |
| 27 | + pub card_bg: Color32, | |
| 28 | + pub popover_bg: Color32, | |
| 29 | + pub shade: Color32, | |
| 30 | + | |
| 31 | + pub accent: Color32, | |
| 32 | + pub accent_fg: Color32, | |
| 33 | + pub accent_hover: Color32, | |
| 34 | + pub accent_active: Color32, | |
| 35 | + | |
| 36 | + pub destructive: Color32, | |
| 37 | + pub success: Color32, | |
| 38 | + pub warning: Color32, | |
| 39 | + | |
| 40 | + pub text: Color32, | |
| 41 | + pub text_secondary: Color32, | |
| 42 | + pub text_disabled: Color32, | |
| 43 | + | |
| 44 | + pub border: Color32, | |
| 45 | + pub border_soft: Color32, | |
| 46 | + | |
| 47 | + pub button_bg: Color32, | |
| 48 | + pub button_hover: Color32, | |
| 49 | + pub button_active: Color32, | |
| 50 | + pub button_fg: Color32, | |
| 51 | +} | |
| 52 | + | |
| 53 | +impl Palette { | |
| 54 | + pub fn dark() -> Self { | |
| 55 | + Self { | |
| 56 | + window_bg: rgb(0x24, 0x24, 0x24), | |
| 57 | + view_bg: rgb(0x1e, 0x1e, 0x1e), | |
| 58 | + headerbar_bg: rgb(0x30, 0x30, 0x30), | |
| 59 | + card_bg: rgb(0x30, 0x30, 0x30), | |
| 60 | + popover_bg: rgb(0x38, 0x38, 0x38), | |
| 61 | + shade: Color32::from_black_alpha(80), | |
| 62 | + | |
| 63 | + accent: rgb(0x35, 0x84, 0xe4), | |
| 64 | + accent_fg: Color32::WHITE, | |
| 65 | + accent_hover: rgb(0x4a, 0x93, 0xe7), | |
| 66 | + accent_active: rgb(0x1c, 0x71, 0xd8), | |
| 67 | + | |
| 68 | + destructive: rgb(0xc0, 0x1c, 0x28), | |
| 69 | + success: rgb(0x2e, 0xc2, 0x7e), | |
| 70 | + warning: rgb(0xe5, 0xa5, 0x0a), | |
| 71 | + | |
| 72 | + text: rgb(0xff, 0xff, 0xff), | |
| 73 | + text_secondary: rgb(0x9a, 0x99, 0x96), | |
| 74 | + text_disabled: rgb(0x5e, 0x5c, 0x64), | |
| 75 | + | |
| 76 | + border: rgb(0x5e, 0x5c, 0x64), | |
| 77 | + border_soft: rgb(0x3d, 0x38, 0x46), | |
| 78 | + | |
| 79 | + button_bg: rgb(0x3d, 0x3d, 0x3d), | |
| 80 | + button_hover: rgb(0x4a, 0x4a, 0x4a), | |
| 81 | + button_active: rgb(0x35, 0x35, 0x35), | |
| 82 | + button_fg: rgb(0xff, 0xff, 0xff), | |
| 83 | + } | |
| 84 | + } | |
| 85 | + | |
| 86 | + pub fn light() -> Self { | |
| 87 | + Self { | |
| 88 | + window_bg: rgb(0xfa, 0xfa, 0xfa), | |
| 89 | + view_bg: rgb(0xff, 0xff, 0xff), | |
| 90 | + headerbar_bg: rgb(0xff, 0xff, 0xff), | |
| 91 | + card_bg: rgb(0xff, 0xff, 0xff), | |
| 92 | + popover_bg: rgb(0xff, 0xff, 0xff), | |
| 93 | + shade: Color32::from_black_alpha(40), | |
| 94 | + | |
| 95 | + accent: rgb(0x35, 0x84, 0xe4), | |
| 96 | + accent_fg: Color32::WHITE, | |
| 97 | + accent_hover: rgb(0x4a, 0x93, 0xe7), | |
| 98 | + accent_active: rgb(0x1c, 0x71, 0xd8), | |
| 99 | + | |
| 100 | + destructive: rgb(0xc0, 0x1c, 0x28), | |
| 101 | + success: rgb(0x26, 0xa2, 0x69), | |
| 102 | + warning: rgb(0xe5, 0xa5, 0x0a), | |
| 103 | + | |
| 104 | + text: rgb(0x24, 0x1f, 0x31), | |
| 105 | + text_secondary: rgb(0x5e, 0x5c, 0x64), | |
| 106 | + text_disabled: rgb(0x9a, 0x99, 0x96), | |
| 107 | + | |
| 108 | + border: rgb(0xcd, 0xcd, 0xcd), | |
| 109 | + border_soft: rgb(0xe0, 0xe0, 0xe0), | |
| 110 | + | |
| 111 | + button_bg: rgb(0xe0, 0xe0, 0xe0), | |
| 112 | + button_hover: rgb(0xd0, 0xd0, 0xd0), | |
| 113 | + button_active: rgb(0xc0, 0xc0, 0xc0), | |
| 114 | + button_fg: rgb(0x24, 0x1f, 0x31), | |
| 115 | + } | |
| 116 | + } | |
| 117 | +} | |
| 118 | + | |
| 119 | +/// Spacing scale (HIG-ish: 6 / 12 / 18 / 24). | |
| 120 | +#[derive(Debug, Clone)] | |
| 121 | +pub struct Spacing { | |
| 122 | + pub xs: f32, | |
| 123 | + pub sm: f32, | |
| 124 | + pub md: f32, | |
| 125 | + pub lg: f32, | |
| 126 | + pub xl: f32, | |
| 127 | + pub page: f32, | |
| 128 | + pub control_height: f32, | |
| 129 | + /// Inner horizontal padding for text fields (egui default is only 4). | |
| 130 | + pub field_pad_x: f32, | |
| 131 | + /// Inner vertical padding for text fields (egui default is only 2). | |
| 132 | + pub field_pad_y: f32, | |
| 133 | + /// Gap between a form label and its control. | |
| 134 | + pub field_gap: f32, | |
| 135 | + pub radius_sm: f32, | |
| 136 | + pub radius_md: f32, | |
| 137 | + pub radius_lg: f32, | |
| 138 | +} | |
| 139 | + | |
| 140 | +impl Default for Spacing { | |
| 141 | + fn default() -> Self { | |
| 142 | + Self { | |
| 143 | + xs: 4.0, | |
| 144 | + sm: 6.0, | |
| 145 | + md: 12.0, | |
| 146 | + lg: 18.0, | |
| 147 | + xl: 24.0, | |
| 148 | + page: 16.0, | |
| 149 | + control_height: 34.0, | |
| 150 | + field_pad_x: 12.0, | |
| 151 | + field_pad_y: 8.0, | |
| 152 | + field_gap: 12.0, | |
| 153 | + radius_sm: 6.0, | |
| 154 | + radius_md: 9.0, | |
| 155 | + radius_lg: 12.0, | |
| 156 | + } | |
| 157 | + } | |
| 158 | +} | |
| 159 | + | |
| 160 | +/// Type sizes (UI sans scale). | |
| 161 | +#[derive(Debug, Clone)] | |
| 162 | +pub struct TypeScale { | |
| 163 | + pub title: f32, | |
| 164 | + pub title_2: f32, | |
| 165 | + pub title_3: f32, | |
| 166 | + pub body: f32, | |
| 167 | + pub caption: f32, | |
| 168 | +} | |
| 169 | + | |
| 170 | +impl Default for TypeScale { | |
| 171 | + fn default() -> Self { | |
| 172 | + Self { | |
| 173 | + title: 20.0, | |
| 174 | + title_2: 16.0, | |
| 175 | + title_3: 14.0, | |
| 176 | + body: 14.0, | |
| 177 | + caption: 12.0, | |
| 178 | + } | |
| 179 | + } | |
| 180 | +} | |
| 181 | + | |
| 182 | +/// Full theme bundle. | |
| 183 | +#[derive(Debug, Clone)] | |
| 184 | +pub struct Theme { | |
| 185 | + pub mode: Mode, | |
| 186 | + pub palette: Palette, | |
| 187 | + pub spacing: Spacing, | |
| 188 | + pub type_scale: TypeScale, | |
| 189 | +} | |
| 190 | + | |
| 191 | +impl Theme { | |
| 192 | + pub fn dark() -> Self { | |
| 193 | + Self { | |
| 194 | + mode: Mode::Dark, | |
| 195 | + palette: Palette::dark(), | |
| 196 | + spacing: Spacing::default(), | |
| 197 | + type_scale: TypeScale::default(), | |
| 198 | + } | |
| 199 | + } | |
| 200 | + | |
| 201 | + pub fn light() -> Self { | |
| 202 | + Self { | |
| 203 | + mode: Mode::Light, | |
| 204 | + palette: Palette::light(), | |
| 205 | + spacing: Spacing::default(), | |
| 206 | + type_scale: TypeScale::default(), | |
| 207 | + } | |
| 208 | + } | |
| 209 | + | |
| 210 | + pub fn visuals(&self) -> Visuals { | |
| 211 | + let p = &self.palette; | |
| 212 | + let mut v = match self.mode { | |
| 213 | + Mode::Dark => Visuals::dark(), | |
| 214 | + Mode::Light => Visuals::light(), | |
| 215 | + }; | |
| 216 | + | |
| 217 | + v.dark_mode = self.mode == Mode::Dark; | |
| 218 | + v.override_text_color = Some(p.text); | |
| 219 | + v.hyperlink_color = p.accent; | |
| 220 | + v.warn_fg_color = p.warning; | |
| 221 | + v.error_fg_color = p.destructive; | |
| 222 | + v.extreme_bg_color = p.view_bg; | |
| 223 | + v.faint_bg_color = p.card_bg; | |
| 224 | + v.code_bg_color = p.popover_bg; | |
| 225 | + v.window_fill = p.window_bg; | |
| 226 | + v.panel_fill = p.window_bg; | |
| 227 | + v.window_stroke = Stroke::new(1.0_f32, p.border_soft); | |
| 228 | + v.window_corner_radius = CornerRadius::same(self.spacing.radius_md as u8); | |
| 229 | + v.window_shadow = Shadow { | |
| 230 | + offset: [0, 4], | |
| 231 | + blur: 16, | |
| 232 | + spread: 0, | |
| 233 | + color: p.shade, | |
| 234 | + }; | |
| 235 | + v.popup_shadow = Shadow { | |
| 236 | + offset: [0, 2], | |
| 237 | + blur: 8, | |
| 238 | + spread: 0, | |
| 239 | + color: p.shade, | |
| 240 | + }; | |
| 241 | + v.selection.bg_fill = p.accent.gamma_multiply(0.35); | |
| 242 | + v.selection.stroke = Stroke::new(1.0_f32, p.accent); | |
| 243 | + v.widgets = widget_visuals(p, &self.spacing); | |
| 244 | + // Browser-style click affordance: buttons (and apps that opt in) show a | |
| 245 | + // pointing-hand cursor on hover. egui defaults this to None (native toolkit | |
| 246 | + // style); Vidya prefers the clickable cue on desktop. | |
| 247 | + v.interact_cursor = Some(CursorIcon::PointingHand); | |
| 248 | + v | |
| 249 | + } | |
| 250 | + | |
| 251 | + pub fn style(&self) -> Style { | |
| 252 | + let mut style = Style { | |
| 253 | + visuals: self.visuals(), | |
| 254 | + ..Style::default() | |
| 255 | + }; | |
| 256 | + install_text_styles(&mut style, &self.type_scale); | |
| 257 | + | |
| 258 | + let sp = &self.spacing; | |
| 259 | + style.spacing.item_spacing = Vec2::new(sp.sm, sp.sm); | |
| 260 | + style.spacing.button_padding = Vec2::new(sp.md, sp.sm); | |
| 261 | + style.spacing.indent = sp.lg; | |
| 262 | + style.spacing.window_margin = Margin::same(sp.page as i8); | |
| 263 | + style.spacing.menu_margin = Margin::same(sp.sm as i8); | |
| 264 | + style.spacing.interact_size = Vec2::new(sp.control_height * 2.5, sp.control_height); | |
| 265 | + // Native checkbox / radio icons (prefer `checkbox()` for the themed control). | |
| 266 | + style.spacing.icon_width = 18.0; | |
| 267 | + style.spacing.icon_width_inner = 12.0; | |
| 268 | + style.spacing.icon_spacing = sp.sm + 2.0; | |
| 269 | + // Solid gutters: floating bars sit on top of content (egui default). HIG-ish | |
| 270 | + // scroll areas reserve space so lists/text never sit under the thumb. | |
| 271 | + style.spacing.scroll = ScrollStyle::solid(); | |
| 272 | + // Prefer wrapping at the parent edge over extending (overflow) or hard | |
| 273 | + // truncate. Pair with layout helpers (`fit_width`, `card`, `hflow`) so | |
| 274 | + // rows actually receive a finite residual width. | |
| 275 | + style.wrap_mode = Some(TextWrapMode::Wrap); | |
| 276 | + // Default TextEdit desired width: fill parent residual (egui still | |
| 277 | + // clamps to available_width). Avoid the stock 280px “always this wide” | |
| 278 | + // intrinsic that pushes horizontal rows past the app view. | |
| 279 | + style.spacing.text_edit_width = 10_000.0; | |
| 280 | + style.interaction.tooltip_delay = 0.4; | |
| 281 | + style.animation_time = 0.12; | |
| 282 | + style | |
| 283 | + } | |
| 284 | + | |
| 285 | + pub fn header_frame(&self) -> Frame { | |
| 286 | + Frame::new() | |
| 287 | + .fill(self.palette.headerbar_bg) | |
| 288 | + .inner_margin(Margin::symmetric( | |
| 289 | + self.spacing.page as i8, | |
| 290 | + self.spacing.md as i8, | |
| 291 | + )) | |
| 292 | + .stroke(Stroke::new(1.0_f32, self.palette.border_soft)) | |
| 293 | + } | |
| 294 | + | |
| 295 | + pub fn card_frame(&self) -> Frame { | |
| 296 | + Frame::new() | |
| 297 | + .fill(self.palette.card_bg) | |
| 298 | + .corner_radius(self.spacing.radius_lg) | |
| 299 | + .inner_margin(Margin::same(self.spacing.md as i8)) | |
| 300 | + .stroke(Stroke::new(1.0_f32, self.palette.border_soft)) | |
| 301 | + } | |
| 302 | + | |
| 303 | + pub fn page_frame(&self) -> Frame { | |
| 304 | + Frame::new() | |
| 305 | + .fill(self.palette.window_bg) | |
| 306 | + .inner_margin(Margin::same(self.spacing.page as i8)) | |
| 307 | + } | |
| 308 | + | |
| 309 | + /// Inner padding for [`TextEdit`] / form fields (HIG-ish; roomier than egui’s 4×2 default). | |
| 310 | + pub fn text_edit_margin(&self) -> Margin { | |
| 311 | + Margin::symmetric( | |
| 312 | + self.spacing.field_pad_x as i8, | |
| 313 | + self.spacing.field_pad_y as i8, | |
| 314 | + ) | |
| 315 | + } | |
| 316 | +} | |
| 317 | + | |
| 318 | +fn widget_visuals(p: &Palette, sp: &Spacing) -> egui::style::Widgets { | |
| 319 | + let r = CornerRadius::same(sp.radius_md as u8); | |
| 320 | + let soft = Stroke::new(1.0_f32, p.border_soft); | |
| 321 | + | |
| 322 | + egui::style::Widgets { | |
| 323 | + noninteractive: WidgetVisuals { | |
| 324 | + bg_fill: p.card_bg, | |
| 325 | + weak_bg_fill: p.view_bg, | |
| 326 | + bg_stroke: soft, | |
| 327 | + corner_radius: r, | |
| 328 | + fg_stroke: Stroke::new(1.0_f32, p.text_secondary), | |
| 329 | + expansion: 0.0, | |
| 330 | + }, | |
| 331 | + inactive: WidgetVisuals { | |
| 332 | + bg_fill: p.button_bg, | |
| 333 | + weak_bg_fill: p.button_bg, | |
| 334 | + bg_stroke: Stroke::new(1.0_f32, p.border_soft), | |
| 335 | + corner_radius: r, | |
| 336 | + fg_stroke: Stroke::new(1.0_f32, p.button_fg), | |
| 337 | + expansion: 0.0, | |
| 338 | + }, | |
| 339 | + hovered: WidgetVisuals { | |
| 340 | + bg_fill: p.button_hover, | |
| 341 | + weak_bg_fill: p.button_hover, | |
| 342 | + bg_stroke: Stroke::new(1.0_f32, p.border), | |
| 343 | + corner_radius: r, | |
| 344 | + fg_stroke: Stroke::new(1.0_f32, p.text), | |
| 345 | + expansion: 0.0, | |
| 346 | + }, | |
| 347 | + active: WidgetVisuals { | |
| 348 | + bg_fill: p.button_active, | |
| 349 | + weak_bg_fill: p.button_active, | |
| 350 | + bg_stroke: Stroke::new(1.0_f32, p.accent), | |
| 351 | + corner_radius: r, | |
| 352 | + fg_stroke: Stroke::new(1.0_f32, p.text), | |
| 353 | + expansion: 0.0, | |
| 354 | + }, | |
| 355 | + open: WidgetVisuals { | |
| 356 | + bg_fill: p.popover_bg, | |
| 357 | + weak_bg_fill: p.popover_bg, | |
| 358 | + bg_stroke: Stroke::new(1.0_f32, p.border), | |
| 359 | + corner_radius: r, | |
| 360 | + fg_stroke: Stroke::new(1.0_f32, p.text), | |
| 361 | + expansion: 0.0, | |
| 362 | + }, | |
| 363 | + } | |
| 364 | +} | |
| 365 | + | |
| 366 | +/// Square tool button with a stroke/emoji [`crate::Icon`] (e.g. copy, plus). | |
| 367 | +/// | |
| 368 | +/// Prefer this over text labels in tight rows — text wraps under | |
| 369 | +/// [`TextWrapMode::Wrap`] and looks broken at small widths. | |
| 370 | +pub fn icon_button(ui: &mut Ui, theme: &Theme, icon: crate::Icon, tip: &str) -> Response { | |
| 371 | + use crate::paint_icon_in; | |
| 372 | + let p = &theme.palette; | |
| 373 | + let size = theme.spacing.control_height; | |
| 374 | + let (rect, mut response) = ui.allocate_exact_size(Vec2::splat(size), Sense::click()); | |
| 375 | + response = response.on_hover_text(tip); | |
| 376 | + if let Some(cursor) = ui.visuals().interact_cursor { | |
| 377 | + response = response.on_hover_cursor(cursor); | |
| 378 | + } | |
| 379 | + | |
| 380 | + if ui.is_rect_visible(rect) { | |
| 381 | + let hovered = response.hovered() && ui.is_enabled(); | |
| 382 | + let active = response.is_pointer_button_down_on(); | |
| 383 | + let fill = if active { | |
| 384 | + p.button_active | |
| 385 | + } else if hovered { | |
| 386 | + p.button_hover | |
| 387 | + } else { | |
| 388 | + p.button_bg | |
| 389 | + }; | |
| 390 | + let border = if hovered { | |
| 391 | + Stroke::new(1.0_f32, p.border) | |
| 392 | + } else { | |
| 393 | + Stroke::new(1.0_f32, p.border_soft) | |
| 394 | + }; | |
| 395 | + ui.painter().rect( | |
| 396 | + rect, | |
| 397 | + theme.spacing.radius_md, | |
| 398 | + fill, | |
| 399 | + border, | |
| 400 | + StrokeKind::Inside, | |
| 401 | + ); | |
| 402 | + let icon_size = size * 0.48; | |
| 403 | + let icon_rect = egui::Rect::from_center_size(rect.center(), Vec2::splat(icon_size)); | |
| 404 | + paint_icon_in(ui, icon_rect, icon, p.button_fg); | |
| 405 | + } | |
| 406 | + | |
| 407 | + response | |
| 408 | +} | |
| 409 | + | |
| 410 | +/// Primary / suggested action (accent filled). | |
| 411 | +pub fn primary_button(ui: &mut Ui, theme: &Theme, label: &str) -> Response { | |
| 412 | + let p = &theme.palette; | |
| 413 | + let text = RichText::new(label) | |
| 414 | + .size(theme.type_scale.body) | |
| 415 | + .color(p.accent_fg); | |
| 416 | + let btn = Button::new(text) | |
| 417 | + .fill(p.accent) | |
| 418 | + .stroke(Stroke::NONE) | |
| 419 | + .corner_radius(theme.spacing.radius_md) | |
| 420 | + .min_size(Vec2::new(0.0, theme.spacing.control_height)); | |
| 421 | + ui.add(btn) | |
| 422 | +} | |
| 423 | + | |
| 424 | +/// Regular flat button. | |
| 425 | +pub fn button(ui: &mut Ui, theme: &Theme, label: &str) -> Response { | |
| 426 | + let p = &theme.palette; | |
| 427 | + let text = RichText::new(label) | |
| 428 | + .size(theme.type_scale.body) | |
| 429 | + .color(p.button_fg); | |
| 430 | + let btn = Button::new(text) | |
| 431 | + .fill(p.button_bg) | |
| 432 | + .stroke(Stroke::new(1.0_f32, p.border_soft)) | |
| 433 | + .corner_radius(theme.spacing.radius_md) | |
| 434 | + .min_size(Vec2::new(0.0, theme.spacing.control_height)); | |
| 435 | + ui.add(btn) | |
| 436 | +} | |
| 437 | + | |
| 438 | +/// Destructive action. | |
| 439 | +pub fn destructive_button(ui: &mut Ui, theme: &Theme, label: &str) -> Response { | |
| 440 | + let p = &theme.palette; | |
| 441 | + let text = RichText::new(label) | |
| 442 | + .size(theme.type_scale.body) | |
| 443 | + .color(Color32::WHITE); | |
| 444 | + let btn = Button::new(text) | |
| 445 | + .fill(p.destructive) | |
| 446 | + .stroke(Stroke::NONE) | |
| 447 | + .corner_radius(theme.spacing.radius_md) | |
| 448 | + .min_size(Vec2::new(0.0, theme.spacing.control_height)); | |
| 449 | + ui.add(btn) | |
| 450 | +} | |
| 451 | + | |
| 452 | +/// Accent-filled checkbox with a drawn checkmark (stock egui looks janky here). | |
| 453 | +/// | |
| 454 | +/// Unchecked: soft fill + border. Checked: solid accent tile + white stroke check. | |
| 455 | +pub fn checkbox(ui: &mut Ui, theme: &Theme, checked: &mut bool, text: &str) -> Response { | |
| 456 | + let p = &theme.palette; | |
| 457 | + let sp = &theme.spacing; | |
| 458 | + | |
| 459 | + // Slightly larger than stock 16px so the check has room to breathe. | |
| 460 | + let box_size = 20.0_f32; | |
| 461 | + let gap = sp.sm + 4.0; | |
| 462 | + | |
| 463 | + let galley = ui.fonts(|f| { | |
| 464 | + f.layout_no_wrap( | |
| 465 | + text.to_owned(), | |
| 466 | + FontId::proportional(theme.type_scale.body), | |
| 467 | + p.text, | |
| 468 | + ) | |
| 469 | + }); | |
| 470 | + | |
| 471 | + let height = box_size | |
| 472 | + .max(galley.size().y + 4.0) | |
| 473 | + .max(sp.control_height * 0.85); | |
| 474 | + let width = box_size + gap + galley.size().x; | |
| 475 | + let (rect, mut response) = ui.allocate_exact_size(Vec2::new(width, height), Sense::click()); | |
| 476 | + | |
| 477 | + if response.clicked() { | |
| 478 | + *checked = !*checked; | |
| 479 | + response.mark_changed(); | |
| 480 | + } | |
| 481 | + response.widget_info(|| { | |
| 482 | + WidgetInfo::selected(WidgetType::Checkbox, ui.is_enabled(), *checked, text) | |
| 483 | + }); | |
| 484 | + // Custom widget — egui only applies `interact_cursor` on stock `Button`. | |
| 485 | + if let Some(cursor) = ui.visuals().interact_cursor { | |
| 486 | + response = response.on_hover_cursor(cursor); | |
| 487 | + } | |
| 488 | + | |
| 489 | + if ui.is_rect_visible(rect) { | |
| 490 | + let painter = ui.painter(); | |
| 491 | + let box_rect = egui::Rect::from_center_size( | |
| 492 | + pos2(rect.left() + box_size * 0.5, rect.center().y), | |
| 493 | + Vec2::splat(box_size), | |
| 494 | + ); | |
| 495 | + // Squarer than widget radius_md (which nearly circles a 20px tile). | |
| 496 | + let radius = CornerRadius::same((sp.radius_sm * 0.85) as u8); | |
| 497 | + | |
| 498 | + let hovered = response.hovered() && ui.is_enabled(); | |
| 499 | + let (fill, border) = if *checked { | |
| 500 | + let fill = if hovered { p.accent_hover } else { p.accent }; | |
| 501 | + (fill, Stroke::new(1.0_f32, fill)) | |
| 502 | + } else if hovered { | |
| 503 | + (p.button_hover, Stroke::new(1.5_f32, p.border)) | |
| 504 | + } else { | |
| 505 | + (p.button_bg, Stroke::new(1.25_f32, p.border_soft)) | |
| 506 | + }; | |
| 507 | + | |
| 508 | + painter.rect(box_rect, radius, fill, border, StrokeKind::Inside); | |
| 509 | + | |
| 510 | + if *checked { | |
| 511 | + // Refined check proportions (short leg → long arm), not stock’s steep V. | |
| 512 | + let s = box_size; | |
| 513 | + let o = box_rect.min; | |
| 514 | + let a = pos2(o.x + s * 0.22, o.y + s * 0.52); | |
| 515 | + let b = pos2(o.x + s * 0.42, o.y + s * 0.70); | |
| 516 | + let c = pos2(o.x + s * 0.78, o.y + s * 0.30); | |
| 517 | + // Slightly thicker tip stroke reads cleaner at small sizes. | |
| 518 | + painter.add(Shape::line( | |
| 519 | + vec![a, b, c], | |
| 520 | + Stroke::new(2.15_f32, p.accent_fg), | |
| 521 | + )); | |
| 522 | + } | |
| 523 | + | |
| 524 | + let text_pos = pos2( | |
| 525 | + rect.left() + box_size + gap, | |
| 526 | + rect.center().y - 0.5 * galley.size().y, | |
| 527 | + ); | |
| 528 | + painter.galley(text_pos, galley, p.text); | |
| 529 | + } | |
| 530 | + | |
| 531 | + response | |
| 532 | +} | |
| 533 | + | |
| 534 | +/// Live / offline status mark drawn with shapes. | |
| 535 | +/// | |
| 536 | +/// **Do not** use Unicode `●` / `○` in labels on Android: egui’s default fonts | |
| 537 | +/// often lack those glyphs and render a hollow box (“tofu”). This paints a real | |
| 538 | +/// circle instead — filled when `live`, stroked when offline. | |
| 539 | +pub fn status_dot(ui: &mut Ui, theme: &Theme, live: bool) -> Response { | |
| 540 | + let p = &theme.palette; | |
| 541 | + // Match body line height so the dot sits cleanly next to `body` text. | |
| 542 | + let line = theme.type_scale.body; | |
| 543 | + let diameter = (line * 0.55).clamp(7.0, 12.0); | |
| 544 | + let pad = 2.0; | |
| 545 | + let (rect, response) = | |
| 546 | + ui.allocate_exact_size(Vec2::new(diameter + pad * 2.0, line), Sense::hover()); | |
| 547 | + | |
| 548 | + if ui.is_rect_visible(rect) { | |
| 549 | + let center = pos2(rect.center().x, rect.center().y); | |
| 550 | + let radius = diameter * 0.5; | |
| 551 | + let color = if live { p.success } else { p.text_secondary }; | |
| 552 | + let painter = ui.painter(); | |
| 553 | + if live { | |
| 554 | + painter.circle_filled(center, radius, color); | |
| 555 | + } else { | |
| 556 | + painter.circle_stroke(center, radius, Stroke::new(1.5_f32, color)); | |
| 557 | + } | |
| 558 | + } | |
| 559 | + | |
| 560 | + response | |
| 561 | +} | |
| 562 | + | |
| 563 | +pub fn title(ui: &mut Ui, theme: &Theme, text: &str) { | |
| 564 | + ui.add( | |
| 565 | + Label::new( | |
| 566 | + RichText::new(text) | |
| 567 | + .size(theme.type_scale.title) | |
| 568 | + .strong() | |
| 569 | + .color(theme.palette.text), | |
| 570 | + ) | |
| 571 | + .wrap(), | |
| 572 | + ); | |
| 573 | +} | |
| 574 | + | |
| 575 | +pub fn title_2(ui: &mut Ui, theme: &Theme, text: &str) { | |
| 576 | + ui.add( | |
| 577 | + Label::new( | |
| 578 | + RichText::new(text) | |
| 579 | + .size(theme.type_scale.title_2) | |
| 580 | + .strong() | |
| 581 | + .color(theme.palette.text), | |
| 582 | + ) | |
| 583 | + .wrap(), | |
| 584 | + ); | |
| 585 | +} | |
| 586 | + | |
| 587 | +pub fn body(ui: &mut Ui, theme: &Theme, text: &str) { | |
| 588 | + ui.add( | |
| 589 | + Label::new( | |
| 590 | + RichText::new(text) | |
| 591 | + .size(theme.type_scale.body) | |
| 592 | + .color(theme.palette.text), | |
| 593 | + ) | |
| 594 | + .wrap(), | |
| 595 | + ); | |
| 596 | +} | |
| 597 | + | |
| 598 | +pub fn dim_label(ui: &mut Ui, theme: &Theme, text: &str) { | |
| 599 | + ui.add( | |
| 600 | + Label::new( | |
| 601 | + RichText::new(text) | |
| 602 | + .size(theme.type_scale.caption) | |
| 603 | + .color(theme.palette.text_secondary), | |
| 604 | + ) | |
| 605 | + .wrap(), | |
| 606 | + ); | |
| 607 | +} | |
| 608 | + | |
| 609 | +/// Single-line text field with theme field padding. | |
| 610 | +/// | |
| 611 | +/// Desired width tracks the parent residual so the field fills its slot | |
| 612 | +/// instead of requesting a fixed 280px and clipping siblings off-screen. | |
| 613 | +pub fn text_field_singleline(ui: &mut Ui, theme: &Theme, text: &mut dyn TextBuffer) -> Response { | |
| 614 | + let w = ui.available_width().max(1.0); | |
| 615 | + ui.add( | |
| 616 | + TextEdit::singleline(text) | |
| 617 | + .margin(theme.text_edit_margin()) | |
| 618 | + .desired_width(w) | |
| 619 | + .min_size(Vec2::new(0.0, theme.spacing.control_height)), | |
| 620 | + ) | |
| 621 | +} | |
| 622 | + | |
| 623 | +/// Multi-line text field with theme field padding. | |
| 624 | +/// | |
| 625 | +/// Fills parent width (clamped by available space) so it stays inside the view. | |
| 626 | +pub fn text_field_multiline( | |
| 627 | + ui: &mut Ui, | |
| 628 | + theme: &Theme, | |
| 629 | + text: &mut dyn TextBuffer, | |
| 630 | + rows: usize, | |
| 631 | +) -> Response { | |
| 632 | + let w = ui.available_width().max(1.0); | |
| 633 | + ui.add( | |
| 634 | + TextEdit::multiline(text) | |
| 635 | + .margin(theme.text_edit_margin()) | |
| 636 | + .desired_width(w) | |
| 637 | + .desired_rows(rows), | |
| 638 | + ) | |
| 639 | +} | |
| 640 | + | |
| 641 | +fn rgb(r: u8, g: u8, b: u8) -> Color32 { | |
| 642 | + Color32::from_rgb(r, g, b) | |
| 643 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,643 @@ | |||
| 1 | +//! Palette, metrics, egui Visuals/Style, and widget helpers. | ||
| 2 | + | ||
| 3 | +use egui::{ | ||
| 4 | + pos2, | ||
| 5 | + style::{ScrollStyle, WidgetVisuals}, | ||
| 6 | + Button, Color32, CornerRadius, CursorIcon, FontId, Frame, Label, Margin, Response, RichText, | ||
| 7 | + Sense, Shadow, Shape, Stroke, StrokeKind, Style, TextBuffer, TextEdit, TextWrapMode, Ui, Vec2, | ||
| 8 | + Visuals, WidgetInfo, WidgetType, | ||
| 9 | +}; | ||
| 10 | + | ||
| 11 | +use crate::install_text_styles; | ||
| 12 | + | ||
| 13 | +/// Light vs dark shell. | ||
| 14 | +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] | ||
| 15 | +pub enum Mode { | ||
| 16 | + #[default] | ||
| 17 | + Dark, | ||
| 18 | + Light, | ||
| 19 | +} | ||
| 20 | + | ||
| 21 | +/// Semantic colors. | ||
| 22 | +#[derive(Debug, Clone)] | ||
| 23 | +pub struct Palette { | ||
| 24 | + pub window_bg: Color32, | ||
| 25 | + pub view_bg: Color32, | ||
| 26 | + pub headerbar_bg: Color32, | ||
| 27 | + pub card_bg: Color32, | ||
| 28 | + pub popover_bg: Color32, | ||
| 29 | + pub shade: Color32, | ||
| 30 | + | ||
| 31 | + pub accent: Color32, | ||
| 32 | + pub accent_fg: Color32, | ||
| 33 | + pub accent_hover: Color32, | ||
| 34 | + pub accent_active: Color32, | ||
| 35 | + | ||
| 36 | + pub destructive: Color32, | ||
| 37 | + pub success: Color32, | ||
| 38 | + pub warning: Color32, | ||
| 39 | + | ||
| 40 | + pub text: Color32, | ||
| 41 | + pub text_secondary: Color32, | ||
| 42 | + pub text_disabled: Color32, | ||
| 43 | + | ||
| 44 | + pub border: Color32, | ||
| 45 | + pub border_soft: Color32, | ||
| 46 | + | ||
| 47 | + pub button_bg: Color32, | ||
| 48 | + pub button_hover: Color32, | ||
| 49 | + pub button_active: Color32, | ||
| 50 | + pub button_fg: Color32, | ||
| 51 | +} | ||
| 52 | + | ||
| 53 | +impl Palette { | ||
| 54 | + pub fn dark() -> Self { | ||
| 55 | + Self { | ||
| 56 | + window_bg: rgb(0x24, 0x24, 0x24), | ||
| 57 | + view_bg: rgb(0x1e, 0x1e, 0x1e), | ||
| 58 | + headerbar_bg: rgb(0x30, 0x30, 0x30), | ||
| 59 | + card_bg: rgb(0x30, 0x30, 0x30), | ||
| 60 | + popover_bg: rgb(0x38, 0x38, 0x38), | ||
| 61 | + shade: Color32::from_black_alpha(80), | ||
| 62 | + | ||
| 63 | + accent: rgb(0x35, 0x84, 0xe4), | ||
| 64 | + accent_fg: Color32::WHITE, | ||
| 65 | + accent_hover: rgb(0x4a, 0x93, 0xe7), | ||
| 66 | + accent_active: rgb(0x1c, 0x71, 0xd8), | ||
| 67 | + | ||
| 68 | + destructive: rgb(0xc0, 0x1c, 0x28), | ||
| 69 | + success: rgb(0x2e, 0xc2, 0x7e), | ||
| 70 | + warning: rgb(0xe5, 0xa5, 0x0a), | ||
| 71 | + | ||
| 72 | + text: rgb(0xff, 0xff, 0xff), | ||
| 73 | + text_secondary: rgb(0x9a, 0x99, 0x96), | ||
| 74 | + text_disabled: rgb(0x5e, 0x5c, 0x64), | ||
| 75 | + | ||
| 76 | + border: rgb(0x5e, 0x5c, 0x64), | ||
| 77 | + border_soft: rgb(0x3d, 0x38, 0x46), | ||
| 78 | + | ||
| 79 | + button_bg: rgb(0x3d, 0x3d, 0x3d), | ||
| 80 | + button_hover: rgb(0x4a, 0x4a, 0x4a), | ||
| 81 | + button_active: rgb(0x35, 0x35, 0x35), | ||
| 82 | + button_fg: rgb(0xff, 0xff, 0xff), | ||
| 83 | + } | ||
| 84 | + } | ||
| 85 | + | ||
| 86 | + pub fn light() -> Self { | ||
| 87 | + Self { | ||
| 88 | + window_bg: rgb(0xfa, 0xfa, 0xfa), | ||
| 89 | + view_bg: rgb(0xff, 0xff, 0xff), | ||
| 90 | + headerbar_bg: rgb(0xff, 0xff, 0xff), | ||
| 91 | + card_bg: rgb(0xff, 0xff, 0xff), | ||
| 92 | + popover_bg: rgb(0xff, 0xff, 0xff), | ||
| 93 | + shade: Color32::from_black_alpha(40), | ||
| 94 | + | ||
| 95 | + accent: rgb(0x35, 0x84, 0xe4), | ||
| 96 | + accent_fg: Color32::WHITE, | ||
| 97 | + accent_hover: rgb(0x4a, 0x93, 0xe7), | ||
| 98 | + accent_active: rgb(0x1c, 0x71, 0xd8), | ||
| 99 | + | ||
| 100 | + destructive: rgb(0xc0, 0x1c, 0x28), | ||
| 101 | + success: rgb(0x26, 0xa2, 0x69), | ||
| 102 | + warning: rgb(0xe5, 0xa5, 0x0a), | ||
| 103 | + | ||
| 104 | + text: rgb(0x24, 0x1f, 0x31), | ||
| 105 | + text_secondary: rgb(0x5e, 0x5c, 0x64), | ||
| 106 | + text_disabled: rgb(0x9a, 0x99, 0x96), | ||
| 107 | + | ||
| 108 | + border: rgb(0xcd, 0xcd, 0xcd), | ||
| 109 | + border_soft: rgb(0xe0, 0xe0, 0xe0), | ||
| 110 | + | ||
| 111 | + button_bg: rgb(0xe0, 0xe0, 0xe0), | ||
| 112 | + button_hover: rgb(0xd0, 0xd0, 0xd0), | ||
| 113 | + button_active: rgb(0xc0, 0xc0, 0xc0), | ||
| 114 | + button_fg: rgb(0x24, 0x1f, 0x31), | ||
| 115 | + } | ||
| 116 | + } | ||
| 117 | +} | ||
| 118 | + | ||
| 119 | +/// Spacing scale (HIG-ish: 6 / 12 / 18 / 24). | ||
| 120 | +#[derive(Debug, Clone)] | ||
| 121 | +pub struct Spacing { | ||
| 122 | + pub xs: f32, | ||
| 123 | + pub sm: f32, | ||
| 124 | + pub md: f32, | ||
| 125 | + pub lg: f32, | ||
| 126 | + pub xl: f32, | ||
| 127 | + pub page: f32, | ||
| 128 | + pub control_height: f32, | ||
| 129 | + /// Inner horizontal padding for text fields (egui default is only 4). | ||
| 130 | + pub field_pad_x: f32, | ||
| 131 | + /// Inner vertical padding for text fields (egui default is only 2). | ||
| 132 | + pub field_pad_y: f32, | ||
| 133 | + /// Gap between a form label and its control. | ||
| 134 | + pub field_gap: f32, | ||
| 135 | + pub radius_sm: f32, | ||
| 136 | + pub radius_md: f32, | ||
| 137 | + pub radius_lg: f32, | ||
| 138 | +} | ||
| 139 | + | ||
| 140 | +impl Default for Spacing { | ||
| 141 | + fn default() -> Self { | ||
| 142 | + Self { | ||
| 143 | + xs: 4.0, | ||
| 144 | + sm: 6.0, | ||
| 145 | + md: 12.0, | ||
| 146 | + lg: 18.0, | ||
| 147 | + xl: 24.0, | ||
| 148 | + page: 16.0, | ||
| 149 | + control_height: 34.0, | ||
| 150 | + field_pad_x: 12.0, | ||
| 151 | + field_pad_y: 8.0, | ||
| 152 | + field_gap: 12.0, | ||
| 153 | + radius_sm: 6.0, | ||
| 154 | + radius_md: 9.0, | ||
| 155 | + radius_lg: 12.0, | ||
| 156 | + } | ||
| 157 | + } | ||
| 158 | +} | ||
| 159 | + | ||
| 160 | +/// Type sizes (UI sans scale). | ||
| 161 | +#[derive(Debug, Clone)] | ||
| 162 | +pub struct TypeScale { | ||
| 163 | + pub title: f32, | ||
| 164 | + pub title_2: f32, | ||
| 165 | + pub title_3: f32, | ||
| 166 | + pub body: f32, | ||
| 167 | + pub caption: f32, | ||
| 168 | +} | ||
| 169 | + | ||
| 170 | +impl Default for TypeScale { | ||
| 171 | + fn default() -> Self { | ||
| 172 | + Self { | ||
| 173 | + title: 20.0, | ||
| 174 | + title_2: 16.0, | ||
| 175 | + title_3: 14.0, | ||
| 176 | + body: 14.0, | ||
| 177 | + caption: 12.0, | ||
| 178 | + } | ||
| 179 | + } | ||
| 180 | +} | ||
| 181 | + | ||
| 182 | +/// Full theme bundle. | ||
| 183 | +#[derive(Debug, Clone)] | ||
| 184 | +pub struct Theme { | ||
| 185 | + pub mode: Mode, | ||
| 186 | + pub palette: Palette, | ||
| 187 | + pub spacing: Spacing, | ||
| 188 | + pub type_scale: TypeScale, | ||
| 189 | +} | ||
| 190 | + | ||
| 191 | +impl Theme { | ||
| 192 | + pub fn dark() -> Self { | ||
| 193 | + Self { | ||
| 194 | + mode: Mode::Dark, | ||
| 195 | + palette: Palette::dark(), | ||
| 196 | + spacing: Spacing::default(), | ||
| 197 | + type_scale: TypeScale::default(), | ||
| 198 | + } | ||
| 199 | + } | ||
| 200 | + | ||
| 201 | + pub fn light() -> Self { | ||
| 202 | + Self { | ||
| 203 | + mode: Mode::Light, | ||
| 204 | + palette: Palette::light(), | ||
| 205 | + spacing: Spacing::default(), | ||
| 206 | + type_scale: TypeScale::default(), | ||
| 207 | + } | ||
| 208 | + } | ||
| 209 | + | ||
| 210 | + pub fn visuals(&self) -> Visuals { | ||
| 211 | + let p = &self.palette; | ||
| 212 | + let mut v = match self.mode { | ||
| 213 | + Mode::Dark => Visuals::dark(), | ||
| 214 | + Mode::Light => Visuals::light(), | ||
| 215 | + }; | ||
| 216 | + | ||
| 217 | + v.dark_mode = self.mode == Mode::Dark; | ||
| 218 | + v.override_text_color = Some(p.text); | ||
| 219 | + v.hyperlink_color = p.accent; | ||
| 220 | + v.warn_fg_color = p.warning; | ||
| 221 | + v.error_fg_color = p.destructive; | ||
| 222 | + v.extreme_bg_color = p.view_bg; | ||
| 223 | + v.faint_bg_color = p.card_bg; | ||
| 224 | + v.code_bg_color = p.popover_bg; | ||
| 225 | + v.window_fill = p.window_bg; | ||
| 226 | + v.panel_fill = p.window_bg; | ||
| 227 | + v.window_stroke = Stroke::new(1.0_f32, p.border_soft); | ||
| 228 | + v.window_corner_radius = CornerRadius::same(self.spacing.radius_md as u8); | ||
| 229 | + v.window_shadow = Shadow { | ||
| 230 | + offset: [0, 4], | ||
| 231 | + blur: 16, | ||
| 232 | + spread: 0, | ||
| 233 | + color: p.shade, | ||
| 234 | + }; | ||
| 235 | + v.popup_shadow = Shadow { | ||
| 236 | + offset: [0, 2], | ||
| 237 | + blur: 8, | ||
| 238 | + spread: 0, | ||
| 239 | + color: p.shade, | ||
| 240 | + }; | ||
| 241 | + v.selection.bg_fill = p.accent.gamma_multiply(0.35); | ||
| 242 | + v.selection.stroke = Stroke::new(1.0_f32, p.accent); | ||
| 243 | + v.widgets = widget_visuals(p, &self.spacing); | ||
| 244 | + // Browser-style click affordance: buttons (and apps that opt in) show a | ||
| 245 | + // pointing-hand cursor on hover. egui defaults this to None (native toolkit | ||
| 246 | + // style); Vidya prefers the clickable cue on desktop. | ||
| 247 | + v.interact_cursor = Some(CursorIcon::PointingHand); | ||
| 248 | + v | ||
| 249 | + } | ||
| 250 | + | ||
| 251 | + pub fn style(&self) -> Style { | ||
| 252 | + let mut style = Style { | ||
| 253 | + visuals: self.visuals(), | ||
| 254 | + ..Style::default() | ||
| 255 | + }; | ||
| 256 | + install_text_styles(&mut style, &self.type_scale); | ||
| 257 | + | ||
| 258 | + let sp = &self.spacing; | ||
| 259 | + style.spacing.item_spacing = Vec2::new(sp.sm, sp.sm); | ||
| 260 | + style.spacing.button_padding = Vec2::new(sp.md, sp.sm); | ||
| 261 | + style.spacing.indent = sp.lg; | ||
| 262 | + style.spacing.window_margin = Margin::same(sp.page as i8); | ||
| 263 | + style.spacing.menu_margin = Margin::same(sp.sm as i8); | ||
| 264 | + style.spacing.interact_size = Vec2::new(sp.control_height * 2.5, sp.control_height); | ||
| 265 | + // Native checkbox / radio icons (prefer `checkbox()` for the themed control). | ||
| 266 | + style.spacing.icon_width = 18.0; | ||
| 267 | + style.spacing.icon_width_inner = 12.0; | ||
| 268 | + style.spacing.icon_spacing = sp.sm + 2.0; | ||
| 269 | + // Solid gutters: floating bars sit on top of content (egui default). HIG-ish | ||
| 270 | + // scroll areas reserve space so lists/text never sit under the thumb. | ||
| 271 | + style.spacing.scroll = ScrollStyle::solid(); | ||
| 272 | + // Prefer wrapping at the parent edge over extending (overflow) or hard | ||
| 273 | + // truncate. Pair with layout helpers (`fit_width`, `card`, `hflow`) so | ||
| 274 | + // rows actually receive a finite residual width. | ||
| 275 | + style.wrap_mode = Some(TextWrapMode::Wrap); | ||
| 276 | + // Default TextEdit desired width: fill parent residual (egui still | ||
| 277 | + // clamps to available_width). Avoid the stock 280px “always this wide” | ||
| 278 | + // intrinsic that pushes horizontal rows past the app view. | ||
| 279 | + style.spacing.text_edit_width = 10_000.0; | ||
| 280 | + style.interaction.tooltip_delay = 0.4; | ||
| 281 | + style.animation_time = 0.12; | ||
| 282 | + style | ||
| 283 | + } | ||
| 284 | + | ||
| 285 | + pub fn header_frame(&self) -> Frame { | ||
| 286 | + Frame::new() | ||
| 287 | + .fill(self.palette.headerbar_bg) | ||
| 288 | + .inner_margin(Margin::symmetric( | ||
| 289 | + self.spacing.page as i8, | ||
| 290 | + self.spacing.md as i8, | ||
| 291 | + )) | ||
| 292 | + .stroke(Stroke::new(1.0_f32, self.palette.border_soft)) | ||
| 293 | + } | ||
| 294 | + | ||
| 295 | + pub fn card_frame(&self) -> Frame { | ||
| 296 | + Frame::new() | ||
| 297 | + .fill(self.palette.card_bg) | ||
| 298 | + .corner_radius(self.spacing.radius_lg) | ||
| 299 | + .inner_margin(Margin::same(self.spacing.md as i8)) | ||
| 300 | + .stroke(Stroke::new(1.0_f32, self.palette.border_soft)) | ||
| 301 | + } | ||
| 302 | + | ||
| 303 | + pub fn page_frame(&self) -> Frame { | ||
| 304 | + Frame::new() | ||
| 305 | + .fill(self.palette.window_bg) | ||
| 306 | + .inner_margin(Margin::same(self.spacing.page as i8)) | ||
| 307 | + } | ||
| 308 | + | ||
| 309 | + /// Inner padding for [`TextEdit`] / form fields (HIG-ish; roomier than egui’s 4×2 default). | ||
| 310 | + pub fn text_edit_margin(&self) -> Margin { | ||
| 311 | + Margin::symmetric( | ||
| 312 | + self.spacing.field_pad_x as i8, | ||
| 313 | + self.spacing.field_pad_y as i8, | ||
| 314 | + ) | ||
| 315 | + } | ||
| 316 | +} | ||
| 317 | + | ||
| 318 | +fn widget_visuals(p: &Palette, sp: &Spacing) -> egui::style::Widgets { | ||
| 319 | + let r = CornerRadius::same(sp.radius_md as u8); | ||
| 320 | + let soft = Stroke::new(1.0_f32, p.border_soft); | ||
| 321 | + | ||
| 322 | + egui::style::Widgets { | ||
| 323 | + noninteractive: WidgetVisuals { | ||
| 324 | + bg_fill: p.card_bg, | ||
| 325 | + weak_bg_fill: p.view_bg, | ||
| 326 | + bg_stroke: soft, | ||
| 327 | + corner_radius: r, | ||
| 328 | + fg_stroke: Stroke::new(1.0_f32, p.text_secondary), | ||
| 329 | + expansion: 0.0, | ||
| 330 | + }, | ||
| 331 | + inactive: WidgetVisuals { | ||
| 332 | + bg_fill: p.button_bg, | ||
| 333 | + weak_bg_fill: p.button_bg, | ||
| 334 | + bg_stroke: Stroke::new(1.0_f32, p.border_soft), | ||
| 335 | + corner_radius: r, | ||
| 336 | + fg_stroke: Stroke::new(1.0_f32, p.button_fg), | ||
| 337 | + expansion: 0.0, | ||
| 338 | + }, | ||
| 339 | + hovered: WidgetVisuals { | ||
| 340 | + bg_fill: p.button_hover, | ||
| 341 | + weak_bg_fill: p.button_hover, | ||
| 342 | + bg_stroke: Stroke::new(1.0_f32, p.border), | ||
| 343 | + corner_radius: r, | ||
| 344 | + fg_stroke: Stroke::new(1.0_f32, p.text), | ||
| 345 | + expansion: 0.0, | ||
| 346 | + }, | ||
| 347 | + active: WidgetVisuals { | ||
| 348 | + bg_fill: p.button_active, | ||
| 349 | + weak_bg_fill: p.button_active, | ||
| 350 | + bg_stroke: Stroke::new(1.0_f32, p.accent), | ||
| 351 | + corner_radius: r, | ||
| 352 | + fg_stroke: Stroke::new(1.0_f32, p.text), | ||
| 353 | + expansion: 0.0, | ||
| 354 | + }, | ||
| 355 | + open: WidgetVisuals { | ||
| 356 | + bg_fill: p.popover_bg, | ||
| 357 | + weak_bg_fill: p.popover_bg, | ||
| 358 | + bg_stroke: Stroke::new(1.0_f32, p.border), | ||
| 359 | + corner_radius: r, | ||
| 360 | + fg_stroke: Stroke::new(1.0_f32, p.text), | ||
| 361 | + expansion: 0.0, | ||
| 362 | + }, | ||
| 363 | + } | ||
| 364 | +} | ||
| 365 | + | ||
| 366 | +/// Square tool button with a stroke/emoji [`crate::Icon`] (e.g. copy, plus). | ||
| 367 | +/// | ||
| 368 | +/// Prefer this over text labels in tight rows — text wraps under | ||
| 369 | +/// [`TextWrapMode::Wrap`] and looks broken at small widths. | ||
| 370 | +pub fn icon_button(ui: &mut Ui, theme: &Theme, icon: crate::Icon, tip: &str) -> Response { | ||
| 371 | + use crate::paint_icon_in; | ||
| 372 | + let p = &theme.palette; | ||
| 373 | + let size = theme.spacing.control_height; | ||
| 374 | + let (rect, mut response) = ui.allocate_exact_size(Vec2::splat(size), Sense::click()); | ||
| 375 | + response = response.on_hover_text(tip); | ||
| 376 | + if let Some(cursor) = ui.visuals().interact_cursor { | ||
| 377 | + response = response.on_hover_cursor(cursor); | ||
| 378 | + } | ||
| 379 | + | ||
| 380 | + if ui.is_rect_visible(rect) { | ||
| 381 | + let hovered = response.hovered() && ui.is_enabled(); | ||
| 382 | + let active = response.is_pointer_button_down_on(); | ||
| 383 | + let fill = if active { | ||
| 384 | + p.button_active | ||
| 385 | + } else if hovered { | ||
| 386 | + p.button_hover | ||
| 387 | + } else { | ||
| 388 | + p.button_bg | ||
| 389 | + }; | ||
| 390 | + let border = if hovered { | ||
| 391 | + Stroke::new(1.0_f32, p.border) | ||
| 392 | + } else { | ||
| 393 | + Stroke::new(1.0_f32, p.border_soft) | ||
| 394 | + }; | ||
| 395 | + ui.painter().rect( | ||
| 396 | + rect, | ||
| 397 | + theme.spacing.radius_md, | ||
| 398 | + fill, | ||
| 399 | + border, | ||
| 400 | + StrokeKind::Inside, | ||
| 401 | + ); | ||
| 402 | + let icon_size = size * 0.48; | ||
| 403 | + let icon_rect = egui::Rect::from_center_size(rect.center(), Vec2::splat(icon_size)); | ||
| 404 | + paint_icon_in(ui, icon_rect, icon, p.button_fg); | ||
| 405 | + } | ||
| 406 | + | ||
| 407 | + response | ||
| 408 | +} | ||
| 409 | + | ||
| 410 | +/// Primary / suggested action (accent filled). | ||
| 411 | +pub fn primary_button(ui: &mut Ui, theme: &Theme, label: &str) -> Response { | ||
| 412 | + let p = &theme.palette; | ||
| 413 | + let text = RichText::new(label) | ||
| 414 | + .size(theme.type_scale.body) | ||
| 415 | + .color(p.accent_fg); | ||
| 416 | + let btn = Button::new(text) | ||
| 417 | + .fill(p.accent) | ||
| 418 | + .stroke(Stroke::NONE) | ||
| 419 | + .corner_radius(theme.spacing.radius_md) | ||
| 420 | + .min_size(Vec2::new(0.0, theme.spacing.control_height)); | ||
| 421 | + ui.add(btn) | ||
| 422 | +} | ||
| 423 | + | ||
| 424 | +/// Regular flat button. | ||
| 425 | +pub fn button(ui: &mut Ui, theme: &Theme, label: &str) -> Response { | ||
| 426 | + let p = &theme.palette; | ||
| 427 | + let text = RichText::new(label) | ||
| 428 | + .size(theme.type_scale.body) | ||
| 429 | + .color(p.button_fg); | ||
| 430 | + let btn = Button::new(text) | ||
| 431 | + .fill(p.button_bg) | ||
| 432 | + .stroke(Stroke::new(1.0_f32, p.border_soft)) | ||
| 433 | + .corner_radius(theme.spacing.radius_md) | ||
| 434 | + .min_size(Vec2::new(0.0, theme.spacing.control_height)); | ||
| 435 | + ui.add(btn) | ||
| 436 | +} | ||
| 437 | + | ||
| 438 | +/// Destructive action. | ||
| 439 | +pub fn destructive_button(ui: &mut Ui, theme: &Theme, label: &str) -> Response { | ||
| 440 | + let p = &theme.palette; | ||
| 441 | + let text = RichText::new(label) | ||
| 442 | + .size(theme.type_scale.body) | ||
| 443 | + .color(Color32::WHITE); | ||
| 444 | + let btn = Button::new(text) | ||
| 445 | + .fill(p.destructive) | ||
| 446 | + .stroke(Stroke::NONE) | ||
| 447 | + .corner_radius(theme.spacing.radius_md) | ||
| 448 | + .min_size(Vec2::new(0.0, theme.spacing.control_height)); | ||
| 449 | + ui.add(btn) | ||
| 450 | +} | ||
| 451 | + | ||
| 452 | +/// Accent-filled checkbox with a drawn checkmark (stock egui looks janky here). | ||
| 453 | +/// | ||
| 454 | +/// Unchecked: soft fill + border. Checked: solid accent tile + white stroke check. | ||
| 455 | +pub fn checkbox(ui: &mut Ui, theme: &Theme, checked: &mut bool, text: &str) -> Response { | ||
| 456 | + let p = &theme.palette; | ||
| 457 | + let sp = &theme.spacing; | ||
| 458 | + | ||
| 459 | + // Slightly larger than stock 16px so the check has room to breathe. | ||
| 460 | + let box_size = 20.0_f32; | ||
| 461 | + let gap = sp.sm + 4.0; | ||
| 462 | + | ||
| 463 | + let galley = ui.fonts(|f| { | ||
| 464 | + f.layout_no_wrap( | ||
| 465 | + text.to_owned(), | ||
| 466 | + FontId::proportional(theme.type_scale.body), | ||
| 467 | + p.text, | ||
| 468 | + ) | ||
| 469 | + }); | ||
| 470 | + | ||
| 471 | + let height = box_size | ||
| 472 | + .max(galley.size().y + 4.0) | ||
| 473 | + .max(sp.control_height * 0.85); | ||
| 474 | + let width = box_size + gap + galley.size().x; | ||
| 475 | + let (rect, mut response) = ui.allocate_exact_size(Vec2::new(width, height), Sense::click()); | ||
| 476 | + | ||
| 477 | + if response.clicked() { | ||
| 478 | + *checked = !*checked; | ||
| 479 | + response.mark_changed(); | ||
| 480 | + } | ||
| 481 | + response.widget_info(|| { | ||
| 482 | + WidgetInfo::selected(WidgetType::Checkbox, ui.is_enabled(), *checked, text) | ||
| 483 | + }); | ||
| 484 | + // Custom widget — egui only applies `interact_cursor` on stock `Button`. | ||
| 485 | + if let Some(cursor) = ui.visuals().interact_cursor { | ||
| 486 | + response = response.on_hover_cursor(cursor); | ||
| 487 | + } | ||
| 488 | + | ||
| 489 | + if ui.is_rect_visible(rect) { | ||
| 490 | + let painter = ui.painter(); | ||
| 491 | + let box_rect = egui::Rect::from_center_size( | ||
| 492 | + pos2(rect.left() + box_size * 0.5, rect.center().y), | ||
| 493 | + Vec2::splat(box_size), | ||
| 494 | + ); | ||
| 495 | + // Squarer than widget radius_md (which nearly circles a 20px tile). | ||
| 496 | + let radius = CornerRadius::same((sp.radius_sm * 0.85) as u8); | ||
| 497 | + | ||
| 498 | + let hovered = response.hovered() && ui.is_enabled(); | ||
| 499 | + let (fill, border) = if *checked { | ||
| 500 | + let fill = if hovered { p.accent_hover } else { p.accent }; | ||
| 501 | + (fill, Stroke::new(1.0_f32, fill)) | ||
| 502 | + } else if hovered { | ||
| 503 | + (p.button_hover, Stroke::new(1.5_f32, p.border)) | ||
| 504 | + } else { | ||
| 505 | + (p.button_bg, Stroke::new(1.25_f32, p.border_soft)) | ||
| 506 | + }; | ||
| 507 | + | ||
| 508 | + painter.rect(box_rect, radius, fill, border, StrokeKind::Inside); | ||
| 509 | + | ||
| 510 | + if *checked { | ||
| 511 | + // Refined check proportions (short leg → long arm), not stock’s steep V. | ||
| 512 | + let s = box_size; | ||
| 513 | + let o = box_rect.min; | ||
| 514 | + let a = pos2(o.x + s * 0.22, o.y + s * 0.52); | ||
| 515 | + let b = pos2(o.x + s * 0.42, o.y + s * 0.70); | ||
| 516 | + let c = pos2(o.x + s * 0.78, o.y + s * 0.30); | ||
| 517 | + // Slightly thicker tip stroke reads cleaner at small sizes. | ||
| 518 | + painter.add(Shape::line( | ||
| 519 | + vec![a, b, c], | ||
| 520 | + Stroke::new(2.15_f32, p.accent_fg), | ||
| 521 | + )); | ||
| 522 | + } | ||
| 523 | + | ||
| 524 | + let text_pos = pos2( | ||
| 525 | + rect.left() + box_size + gap, | ||
| 526 | + rect.center().y - 0.5 * galley.size().y, | ||
| 527 | + ); | ||
| 528 | + painter.galley(text_pos, galley, p.text); | ||
| 529 | + } | ||
| 530 | + | ||
| 531 | + response | ||
| 532 | +} | ||
| 533 | + | ||
| 534 | +/// Live / offline status mark drawn with shapes. | ||
| 535 | +/// | ||
| 536 | +/// **Do not** use Unicode `●` / `○` in labels on Android: egui’s default fonts | ||
| 537 | +/// often lack those glyphs and render a hollow box (“tofu”). This paints a real | ||
| 538 | +/// circle instead — filled when `live`, stroked when offline. | ||
| 539 | +pub fn status_dot(ui: &mut Ui, theme: &Theme, live: bool) -> Response { | ||
| 540 | + let p = &theme.palette; | ||
| 541 | + // Match body line height so the dot sits cleanly next to `body` text. | ||
| 542 | + let line = theme.type_scale.body; | ||
| 543 | + let diameter = (line * 0.55).clamp(7.0, 12.0); | ||
| 544 | + let pad = 2.0; | ||
| 545 | + let (rect, response) = | ||
| 546 | + ui.allocate_exact_size(Vec2::new(diameter + pad * 2.0, line), Sense::hover()); | ||
| 547 | + | ||
| 548 | + if ui.is_rect_visible(rect) { | ||
| 549 | + let center = pos2(rect.center().x, rect.center().y); | ||
| 550 | + let radius = diameter * 0.5; | ||
| 551 | + let color = if live { p.success } else { p.text_secondary }; | ||
| 552 | + let painter = ui.painter(); | ||
| 553 | + if live { | ||
| 554 | + painter.circle_filled(center, radius, color); | ||
| 555 | + } else { | ||
| 556 | + painter.circle_stroke(center, radius, Stroke::new(1.5_f32, color)); | ||
| 557 | + } | ||
| 558 | + } | ||
| 559 | + | ||
| 560 | + response | ||
| 561 | +} | ||
| 562 | + | ||
| 563 | +pub fn title(ui: &mut Ui, theme: &Theme, text: &str) { | ||
| 564 | + ui.add( | ||
| 565 | + Label::new( | ||
| 566 | + RichText::new(text) | ||
| 567 | + .size(theme.type_scale.title) | ||
| 568 | + .strong() | ||
| 569 | + .color(theme.palette.text), | ||
| 570 | + ) | ||
| 571 | + .wrap(), | ||
| 572 | + ); | ||
| 573 | +} | ||
| 574 | + | ||
| 575 | +pub fn title_2(ui: &mut Ui, theme: &Theme, text: &str) { | ||
| 576 | + ui.add( | ||
| 577 | + Label::new( | ||
| 578 | + RichText::new(text) | ||
| 579 | + .size(theme.type_scale.title_2) | ||
| 580 | + .strong() | ||
| 581 | + .color(theme.palette.text), | ||
| 582 | + ) | ||
| 583 | + .wrap(), | ||
| 584 | + ); | ||
| 585 | +} | ||
| 586 | + | ||
| 587 | +pub fn body(ui: &mut Ui, theme: &Theme, text: &str) { | ||
| 588 | + ui.add( | ||
| 589 | + Label::new( | ||
| 590 | + RichText::new(text) | ||
| 591 | + .size(theme.type_scale.body) | ||
| 592 | + .color(theme.palette.text), | ||
| 593 | + ) | ||
| 594 | + .wrap(), | ||
| 595 | + ); | ||
| 596 | +} | ||
| 597 | + | ||
| 598 | +pub fn dim_label(ui: &mut Ui, theme: &Theme, text: &str) { | ||
| 599 | + ui.add( | ||
| 600 | + Label::new( | ||
| 601 | + RichText::new(text) | ||
| 602 | + .size(theme.type_scale.caption) | ||
| 603 | + .color(theme.palette.text_secondary), | ||
| 604 | + ) | ||
| 605 | + .wrap(), | ||
| 606 | + ); | ||
| 607 | +} | ||
| 608 | + | ||
| 609 | +/// Single-line text field with theme field padding. | ||
| 610 | +/// | ||
| 611 | +/// Desired width tracks the parent residual so the field fills its slot | ||
| 612 | +/// instead of requesting a fixed 280px and clipping siblings off-screen. | ||
| 613 | +pub fn text_field_singleline(ui: &mut Ui, theme: &Theme, text: &mut dyn TextBuffer) -> Response { | ||
| 614 | + let w = ui.available_width().max(1.0); | ||
| 615 | + ui.add( | ||
| 616 | + TextEdit::singleline(text) | ||
| 617 | + .margin(theme.text_edit_margin()) | ||
| 618 | + .desired_width(w) | ||
| 619 | + .min_size(Vec2::new(0.0, theme.spacing.control_height)), | ||
| 620 | + ) | ||
| 621 | +} | ||
| 622 | + | ||
| 623 | +/// Multi-line text field with theme field padding. | ||
| 624 | +/// | ||
| 625 | +/// Fills parent width (clamped by available space) so it stays inside the view. | ||
| 626 | +pub fn text_field_multiline( | ||
| 627 | + ui: &mut Ui, | ||
| 628 | + theme: &Theme, | ||
| 629 | + text: &mut dyn TextBuffer, | ||
| 630 | + rows: usize, | ||
| 631 | +) -> Response { | ||
| 632 | + let w = ui.available_width().max(1.0); | ||
| 633 | + ui.add( | ||
| 634 | + TextEdit::multiline(text) | ||
| 635 | + .margin(theme.text_edit_margin()) | ||
| 636 | + .desired_width(w) | ||
| 637 | + .desired_rows(rows), | ||
| 638 | + ) | ||
| 639 | +} | ||
| 640 | + | ||
| 641 | +fn rgb(r: u8, g: u8, b: u8) -> Color32 { | ||
| 642 | + Color32::from_rgb(r, g, b) | ||
| 643 | +} | ||
added
crates/vidya-core/src/video.rs +379 -0 | new file mode 100644 | ||
| @@ -0,0 +1,379 @@ | ||
| 1 | +//! Inline video preview / muted H.264-in-MP4 player for egui. | |
| 2 | +//! | |
| 3 | +//! Host apps fetch bytes (SSRF-safe), then mount [`video_player`] with a | |
| 4 | +//! [`VideoPlayerState`]. Decode is behind the `video` cargo feature so default | |
| 5 | +//! `vidya` stays light (theme-only). | |
| 6 | + | |
| 7 | +use std::hash::{Hash, Hasher}; | |
| 8 | +use std::sync::Arc; | |
| 9 | +use std::time::Instant; | |
| 10 | + | |
| 11 | +use egui::{ | |
| 12 | + Align2, Color32, ColorImage, CursorIcon, FontId, Pos2, Rect, Sense, Stroke, TextureHandle, | |
| 13 | + TextureOptions, Ui, Vec2, | |
| 14 | +}; | |
| 15 | + | |
| 16 | +use crate::Theme; | |
| 17 | + | |
| 18 | +#[cfg(feature = "video")] | |
| 19 | +mod avcc; | |
| 20 | +#[cfg(feature = "video")] | |
| 21 | +mod decode; | |
| 22 | + | |
| 23 | +/// Options for [`video_player`]. | |
| 24 | +#[derive(Debug, Clone)] | |
| 25 | +pub struct VideoPlayerOpts { | |
| 26 | + /// Max width of the player surface. | |
| 27 | + pub max_width: f32, | |
| 28 | + /// Max height of the player surface. | |
| 29 | + pub max_height: f32, | |
| 30 | + /// Footer label (filename / host). | |
| 31 | + pub title: Option<String>, | |
| 32 | + /// When set, a failed / unsupported decode falls back to opening this URL. | |
| 33 | + pub open_url_on_unsupported: Option<String>, | |
| 34 | +} | |
| 35 | + | |
| 36 | +impl Default for VideoPlayerOpts { | |
| 37 | + fn default() -> Self { | |
| 38 | + Self { | |
| 39 | + max_width: 320.0, | |
| 40 | + max_height: 288.0, | |
| 41 | + title: None, | |
| 42 | + open_url_on_unsupported: None, | |
| 43 | + } | |
| 44 | + } | |
| 45 | +} | |
| 46 | + | |
| 47 | +/// Outcome of interacting with the player this frame. | |
| 48 | +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] | |
| 49 | +pub enum VideoPlayerAction { | |
| 50 | + #[default] | |
| 51 | + None, | |
| 52 | + /// User asked to open the media externally (unsupported codec / explicit). | |
| 53 | + OpenExternally, | |
| 54 | +} | |
| 55 | + | |
| 56 | +/// Per-embed playback state. Store one of these per video URL (or message id). | |
| 57 | +#[derive(Default)] | |
| 58 | +pub struct VideoPlayerState { | |
| 59 | + content_id: u64, | |
| 60 | + #[cfg(feature = "video")] | |
| 61 | + session: Option<decode::DecodeSession>, | |
| 62 | + texture: Option<TextureHandle>, | |
| 63 | + tex_size: (u32, u32), | |
| 64 | + playing: bool, | |
| 65 | + play_started: Option<Instant>, | |
| 66 | + /// Elapsed media time when paused. | |
| 67 | + paused_at: f64, | |
| 68 | + error: Option<String>, | |
| 69 | + unsupported: bool, | |
| 70 | + loaded: bool, | |
| 71 | +} | |
| 72 | + | |
| 73 | +impl std::fmt::Debug for VideoPlayerState { | |
| 74 | + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { | |
| 75 | + f.debug_struct("VideoPlayerState") | |
| 76 | + .field("content_id", &self.content_id) | |
| 77 | + .field("playing", &self.playing) | |
| 78 | + .field("error", &self.error) | |
| 79 | + .field("unsupported", &self.unsupported) | |
| 80 | + .field("loaded", &self.loaded) | |
| 81 | + .finish() | |
| 82 | + } | |
| 83 | +} | |
| 84 | + | |
| 85 | +impl VideoPlayerState { | |
| 86 | + pub fn new() -> Self { | |
| 87 | + Self::default() | |
| 88 | + } | |
| 89 | + | |
| 90 | + pub fn is_loaded(&self) -> bool { | |
| 91 | + self.loaded | |
| 92 | + } | |
| 93 | + | |
| 94 | + pub fn is_playing(&self) -> bool { | |
| 95 | + self.playing | |
| 96 | + } | |
| 97 | + | |
| 98 | + pub fn error(&self) -> Option<&str> { | |
| 99 | + self.error.as_deref() | |
| 100 | + } | |
| 101 | + | |
| 102 | + pub fn unsupported(&self) -> bool { | |
| 103 | + self.unsupported | |
| 104 | + } | |
| 105 | + | |
| 106 | + /// Load (or reload) MP4 bytes. Idempotent when `id` matches the current content. | |
| 107 | + pub fn load_bytes(&mut self, ctx: &egui::Context, id: impl Hash, bytes: Arc<[u8]>) { | |
| 108 | + let mut hasher = std::collections::hash_map::DefaultHasher::new(); | |
| 109 | + id.hash(&mut hasher); | |
| 110 | + // Also mix length so same path with new bytes reloads. | |
| 111 | + bytes.len().hash(&mut hasher); | |
| 112 | + let content_id = hasher.finish(); | |
| 113 | + if self.loaded && self.content_id == content_id { | |
| 114 | + return; | |
| 115 | + } | |
| 116 | + self.reset(); | |
| 117 | + self.content_id = content_id; | |
| 118 | + | |
| 119 | + #[cfg(feature = "video")] | |
| 120 | + { | |
| 121 | + match decode::DecodeSession::open(bytes) { | |
| 122 | + Ok(mut session) => { | |
| 123 | + if let Some((w, h, rgba)) = session.frame.take() { | |
| 124 | + self.upload_frame(ctx, w, h, &rgba); | |
| 125 | + session.frame = Some((w, h, rgba)); | |
| 126 | + } | |
| 127 | + self.session = Some(session); | |
| 128 | + self.loaded = true; | |
| 129 | + } | |
| 130 | + Err(e) => { | |
| 131 | + self.unsupported = true; | |
| 132 | + self.error = Some(e); | |
| 133 | + self.loaded = true; | |
| 134 | + } | |
| 135 | + } | |
| 136 | + } | |
| 137 | + | |
| 138 | + #[cfg(not(feature = "video"))] | |
| 139 | + { | |
| 140 | + let _ = (ctx, bytes); | |
| 141 | + self.unsupported = true; | |
| 142 | + self.error = Some("Video decode feature not enabled".into()); | |
| 143 | + self.loaded = true; | |
| 144 | + } | |
| 145 | + } | |
| 146 | + | |
| 147 | + pub fn clear(&mut self) { | |
| 148 | + self.reset(); | |
| 149 | + } | |
| 150 | + | |
| 151 | + fn reset(&mut self) { | |
| 152 | + #[cfg(feature = "video")] | |
| 153 | + { | |
| 154 | + self.session = None; | |
| 155 | + } | |
| 156 | + self.texture = None; | |
| 157 | + self.tex_size = (0, 0); | |
| 158 | + self.playing = false; | |
| 159 | + self.play_started = None; | |
| 160 | + self.paused_at = 0.0; | |
| 161 | + self.error = None; | |
| 162 | + self.unsupported = false; | |
| 163 | + self.loaded = false; | |
| 164 | + self.content_id = 0; | |
| 165 | + } | |
| 166 | + | |
| 167 | + fn upload_frame(&mut self, ctx: &egui::Context, w: u32, h: u32, rgba: &[u8]) { | |
| 168 | + let color = ColorImage::from_rgba_unmultiplied([w as usize, h as usize], rgba); | |
| 169 | + match &mut self.texture { | |
| 170 | + Some(tex) if self.tex_size == (w, h) => { | |
| 171 | + tex.set(color, TextureOptions::LINEAR); | |
| 172 | + } | |
| 173 | + _ => { | |
| 174 | + let name = format!("vidya-video-{}", self.content_id); | |
| 175 | + self.texture = Some(ctx.load_texture(name, color, TextureOptions::LINEAR)); | |
| 176 | + self.tex_size = (w, h); | |
| 177 | + } | |
| 178 | + } | |
| 179 | + } | |
| 180 | + | |
| 181 | + fn media_time(&self) -> f64 { | |
| 182 | + if self.playing { | |
| 183 | + let started = self.play_started.unwrap_or_else(Instant::now); | |
| 184 | + self.paused_at + started.elapsed().as_secs_f64() | |
| 185 | + } else { | |
| 186 | + self.paused_at | |
| 187 | + } | |
| 188 | + } | |
| 189 | + | |
| 190 | + fn toggle_play(&mut self, ctx: &egui::Context) { | |
| 191 | + if self.unsupported { | |
| 192 | + return; | |
| 193 | + } | |
| 194 | + if self.playing { | |
| 195 | + self.paused_at = self.media_time(); | |
| 196 | + self.playing = false; | |
| 197 | + self.play_started = None; | |
| 198 | + } else { | |
| 199 | + #[cfg(feature = "video")] | |
| 200 | + { | |
| 201 | + if let Some(session) = self.session.as_ref() { | |
| 202 | + if self.paused_at >= session.duration.as_secs_f64() { | |
| 203 | + self.paused_at = 0.0; | |
| 204 | + } | |
| 205 | + } | |
| 206 | + } | |
| 207 | + self.playing = true; | |
| 208 | + self.play_started = Some(Instant::now()); | |
| 209 | + ctx.request_repaint(); | |
| 210 | + } | |
| 211 | + } | |
| 212 | + | |
| 213 | + #[cfg(feature = "video")] | |
| 214 | + fn tick_decode(&mut self, ctx: &egui::Context) { | |
| 215 | + if !self.playing { | |
| 216 | + return; | |
| 217 | + } | |
| 218 | + let t = self.paused_at | |
| 219 | + + self | |
| 220 | + .play_started | |
| 221 | + .map(|s| s.elapsed().as_secs_f64()) | |
| 222 | + .unwrap_or(0.0); | |
| 223 | + | |
| 224 | + let Some(session) = self.session.as_mut() else { | |
| 225 | + return; | |
| 226 | + }; | |
| 227 | + if let Err(e) = session.seek_playhead(t) { | |
| 228 | + self.error = Some(e); | |
| 229 | + self.playing = false; | |
| 230 | + return; | |
| 231 | + } | |
| 232 | + let frame = session.frame.clone(); | |
| 233 | + let ended = session.ended(t); | |
| 234 | + let duration = session.duration.as_secs_f64(); | |
| 235 | + | |
| 236 | + if let Some((w, h, rgba)) = frame { | |
| 237 | + self.upload_frame(ctx, w, h, &rgba); | |
| 238 | + } | |
| 239 | + if ended { | |
| 240 | + self.playing = false; | |
| 241 | + self.play_started = None; | |
| 242 | + self.paused_at = duration; | |
| 243 | + } else { | |
| 244 | + ctx.request_repaint(); | |
| 245 | + } | |
| 246 | + } | |
| 247 | +} | |
| 248 | + | |
| 249 | +/// Draw a 16:9 (or source-aspect) video surface with play/pause. | |
| 250 | +/// | |
| 251 | +/// Call [`VideoPlayerState::load_bytes`] first when media bytes are ready. | |
| 252 | +pub fn video_player( | |
| 253 | + ui: &mut Ui, | |
| 254 | + theme: &Theme, | |
| 255 | + state: &mut VideoPlayerState, | |
| 256 | + opts: &VideoPlayerOpts, | |
| 257 | +) -> (egui::Response, VideoPlayerAction) { | |
| 258 | + let p = &theme.palette; | |
| 259 | + let sp = &theme.spacing; | |
| 260 | + | |
| 261 | + #[cfg(feature = "video")] | |
| 262 | + state.tick_decode(ui.ctx()); | |
| 263 | + | |
| 264 | + let max_w = ui.available_width().min(opts.max_width).max(120.0); | |
| 265 | + let (src_w, src_h) = if state.tex_size.0 > 0 && state.tex_size.1 > 0 { | |
| 266 | + (state.tex_size.0 as f32, state.tex_size.1 as f32) | |
| 267 | + } else { | |
| 268 | + (16.0, 9.0) | |
| 269 | + }; | |
| 270 | + let height = (max_w * src_h / src_w).min(opts.max_height).max(72.0); | |
| 271 | + let size = Vec2::new(max_w, height); | |
| 272 | + | |
| 273 | + let mut action = VideoPlayerAction::None; | |
| 274 | + | |
| 275 | + let frame_resp = egui::Frame::new() | |
| 276 | + .fill(Color32::from_rgb(12, 12, 14)) | |
| 277 | + .stroke(Stroke::new(1.0_f32, p.border_soft)) | |
| 278 | + .corner_radius(sp.radius_sm) | |
| 279 | + .show(ui, |ui| { | |
| 280 | + let (rect, _) = ui.allocate_exact_size(size, Sense::hover()); | |
| 281 | + | |
| 282 | + if let Some(tex) = state.texture.as_ref() { | |
| 283 | + egui::Image::new((tex.id(), size)).paint_at(ui, rect); | |
| 284 | + } else { | |
| 285 | + ui.painter() | |
| 286 | + .rect_filled(rect, sp.radius_sm, Color32::from_rgb(18, 18, 22)); | |
| 287 | + } | |
| 288 | + | |
| 289 | + // Dim + play/pause affordance when not playing (or never started). | |
| 290 | + if !state.playing { | |
| 291 | + ui.painter().rect_filled( | |
| 292 | + rect, | |
| 293 | + sp.radius_sm, | |
| 294 | + Color32::from_rgba_unmultiplied(0, 0, 0, 48), | |
| 295 | + ); | |
| 296 | + let play_r = (height * 0.18).clamp(16.0, 28.0); | |
| 297 | + let center = rect.center(); | |
| 298 | + ui.painter() | |
| 299 | + .circle_filled(center, play_r, p.accent.gamma_multiply(0.92)); | |
| 300 | + let tri_w = play_r * 0.7; | |
| 301 | + let tri_h = play_r * 0.85; | |
| 302 | + let tip = Pos2::new(center.x + tri_w * 0.55, center.y); | |
| 303 | + let top = Pos2::new(center.x - tri_w * 0.45, center.y - tri_h * 0.5); | |
| 304 | + let bot = Pos2::new(center.x - tri_w * 0.45, center.y + tri_h * 0.5); | |
| 305 | + ui.painter().add(egui::Shape::convex_polygon( | |
| 306 | + vec![tip, bot, top], | |
| 307 | + Color32::from_rgb(255, 255, 255), | |
| 308 | + Stroke::NONE, | |
| 309 | + )); | |
| 310 | + } | |
| 311 | + | |
| 312 | + let footer = if let Some(err) = state.error.as_deref() { | |
| 313 | + Some(err.to_string()) | |
| 314 | + } else if state.playing { | |
| 315 | + Some("Playing…".into()) | |
| 316 | + } else { | |
| 317 | + opts.title.clone() | |
| 318 | + }; | |
| 319 | + if let Some(name) = footer { | |
| 320 | + let foot_h = (theme.type_scale.caption + 10.0).min(height * 0.28); | |
| 321 | + let foot = Rect::from_min_max( | |
| 322 | + Pos2::new(rect.left(), rect.bottom() - foot_h), | |
| 323 | + rect.right_bottom(), | |
| 324 | + ); | |
| 325 | + let r = sp.radius_sm as u8; | |
| 326 | + ui.painter().rect_filled( | |
| 327 | + foot, | |
| 328 | + egui::CornerRadius { | |
| 329 | + nw: 0, | |
| 330 | + ne: 0, | |
| 331 | + sw: r, | |
| 332 | + se: r, | |
| 333 | + }, | |
| 334 | + Color32::from_rgba_unmultiplied(0, 0, 0, 160), | |
| 335 | + ); | |
| 336 | + ui.painter().text( | |
| 337 | + Pos2::new(foot.left() + 8.0, foot.center().y), | |
| 338 | + Align2::LEFT_CENTER, | |
| 339 | + name, | |
| 340 | + FontId::proportional(theme.type_scale.caption), | |
| 341 | + Color32::from_rgb(230, 230, 235), | |
| 342 | + ); | |
| 343 | + } | |
| 344 | + }); | |
| 345 | + | |
| 346 | + let tip = if state.unsupported { | |
| 347 | + state | |
| 348 | + .error | |
| 349 | + .as_deref() | |
| 350 | + .unwrap_or("Video not supported — open externally") | |
| 351 | + } else if state.playing { | |
| 352 | + "Pause" | |
| 353 | + } else { | |
| 354 | + "Play" | |
| 355 | + }; | |
| 356 | + | |
| 357 | + // Same pattern as sleek's OG / fallback cards: interact on the frame rect | |
| 358 | + // so parent bubble layout cannot swallow the click. | |
| 359 | + let resp = ui | |
| 360 | + .interact( | |
| 361 | + frame_resp.response.rect, | |
| 362 | + ui.id().with("vidya_video").with(state.content_id), | |
| 363 | + Sense::click(), | |
| 364 | + ) | |
| 365 | + .on_hover_text(tip) | |
| 366 | + .on_hover_cursor(CursorIcon::PointingHand); | |
| 367 | + | |
| 368 | + if resp.clicked() { | |
| 369 | + if state.unsupported { | |
| 370 | + if opts.open_url_on_unsupported.is_some() { | |
| 371 | + action = VideoPlayerAction::OpenExternally; | |
| 372 | + } | |
| 373 | + } else { | |
| 374 | + state.toggle_play(ui.ctx()); | |
| 375 | + } | |
| 376 | + } | |
| 377 | + | |
| 378 | + (resp, action) | |
| 379 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,379 @@ | |||
| 1 | +//! Inline video preview / muted H.264-in-MP4 player for egui. | ||
| 2 | +//! | ||
| 3 | +//! Host apps fetch bytes (SSRF-safe), then mount [`video_player`] with a | ||
| 4 | +//! [`VideoPlayerState`]. Decode is behind the `video` cargo feature so default | ||
| 5 | +//! `vidya` stays light (theme-only). | ||
| 6 | + | ||
| 7 | +use std::hash::{Hash, Hasher}; | ||
| 8 | +use std::sync::Arc; | ||
| 9 | +use std::time::Instant; | ||
| 10 | + | ||
| 11 | +use egui::{ | ||
| 12 | + Align2, Color32, ColorImage, CursorIcon, FontId, Pos2, Rect, Sense, Stroke, TextureHandle, | ||
| 13 | + TextureOptions, Ui, Vec2, | ||
| 14 | +}; | ||
| 15 | + | ||
| 16 | +use crate::Theme; | ||
| 17 | + | ||
| 18 | +#[cfg(feature = "video")] | ||
| 19 | +mod avcc; | ||
| 20 | +#[cfg(feature = "video")] | ||
| 21 | +mod decode; | ||
| 22 | + | ||
| 23 | +/// Options for [`video_player`]. | ||
| 24 | +#[derive(Debug, Clone)] | ||
| 25 | +pub struct VideoPlayerOpts { | ||
| 26 | + /// Max width of the player surface. | ||
| 27 | + pub max_width: f32, | ||
| 28 | + /// Max height of the player surface. | ||
| 29 | + pub max_height: f32, | ||
| 30 | + /// Footer label (filename / host). | ||
| 31 | + pub title: Option<String>, | ||
| 32 | + /// When set, a failed / unsupported decode falls back to opening this URL. | ||
| 33 | + pub open_url_on_unsupported: Option<String>, | ||
| 34 | +} | ||
| 35 | + | ||
| 36 | +impl Default for VideoPlayerOpts { | ||
| 37 | + fn default() -> Self { | ||
| 38 | + Self { | ||
| 39 | + max_width: 320.0, | ||
| 40 | + max_height: 288.0, | ||
| 41 | + title: None, | ||
| 42 | + open_url_on_unsupported: None, | ||
| 43 | + } | ||
| 44 | + } | ||
| 45 | +} | ||
| 46 | + | ||
| 47 | +/// Outcome of interacting with the player this frame. | ||
| 48 | +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] | ||
| 49 | +pub enum VideoPlayerAction { | ||
| 50 | + #[default] | ||
| 51 | + None, | ||
| 52 | + /// User asked to open the media externally (unsupported codec / explicit). | ||
| 53 | + OpenExternally, | ||
| 54 | +} | ||
| 55 | + | ||
| 56 | +/// Per-embed playback state. Store one of these per video URL (or message id). | ||
| 57 | +#[derive(Default)] | ||
| 58 | +pub struct VideoPlayerState { | ||
| 59 | + content_id: u64, | ||
| 60 | + #[cfg(feature = "video")] | ||
| 61 | + session: Option<decode::DecodeSession>, | ||
| 62 | + texture: Option<TextureHandle>, | ||
| 63 | + tex_size: (u32, u32), | ||
| 64 | + playing: bool, | ||
| 65 | + play_started: Option<Instant>, | ||
| 66 | + /// Elapsed media time when paused. | ||
| 67 | + paused_at: f64, | ||
| 68 | + error: Option<String>, | ||
| 69 | + unsupported: bool, | ||
| 70 | + loaded: bool, | ||
| 71 | +} | ||
| 72 | + | ||
| 73 | +impl std::fmt::Debug for VideoPlayerState { | ||
| 74 | + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { | ||
| 75 | + f.debug_struct("VideoPlayerState") | ||
| 76 | + .field("content_id", &self.content_id) | ||
| 77 | + .field("playing", &self.playing) | ||
| 78 | + .field("error", &self.error) | ||
| 79 | + .field("unsupported", &self.unsupported) | ||
| 80 | + .field("loaded", &self.loaded) | ||
| 81 | + .finish() | ||
| 82 | + } | ||
| 83 | +} | ||
| 84 | + | ||
| 85 | +impl VideoPlayerState { | ||
| 86 | + pub fn new() -> Self { | ||
| 87 | + Self::default() | ||
| 88 | + } | ||
| 89 | + | ||
| 90 | + pub fn is_loaded(&self) -> bool { | ||
| 91 | + self.loaded | ||
| 92 | + } | ||
| 93 | + | ||
| 94 | + pub fn is_playing(&self) -> bool { | ||
| 95 | + self.playing | ||
| 96 | + } | ||
| 97 | + | ||
| 98 | + pub fn error(&self) -> Option<&str> { | ||
| 99 | + self.error.as_deref() | ||
| 100 | + } | ||
| 101 | + | ||
| 102 | + pub fn unsupported(&self) -> bool { | ||
| 103 | + self.unsupported | ||
| 104 | + } | ||
| 105 | + | ||
| 106 | + /// Load (or reload) MP4 bytes. Idempotent when `id` matches the current content. | ||
| 107 | + pub fn load_bytes(&mut self, ctx: &egui::Context, id: impl Hash, bytes: Arc<[u8]>) { | ||
| 108 | + let mut hasher = std::collections::hash_map::DefaultHasher::new(); | ||
| 109 | + id.hash(&mut hasher); | ||
| 110 | + // Also mix length so same path with new bytes reloads. | ||
| 111 | + bytes.len().hash(&mut hasher); | ||
| 112 | + let content_id = hasher.finish(); | ||
| 113 | + if self.loaded && self.content_id == content_id { | ||
| 114 | + return; | ||
| 115 | + } | ||
| 116 | + self.reset(); | ||
| 117 | + self.content_id = content_id; | ||
| 118 | + | ||
| 119 | + #[cfg(feature = "video")] | ||
| 120 | + { | ||
| 121 | + match decode::DecodeSession::open(bytes) { | ||
| 122 | + Ok(mut session) => { | ||
| 123 | + if let Some((w, h, rgba)) = session.frame.take() { | ||
| 124 | + self.upload_frame(ctx, w, h, &rgba); | ||
| 125 | + session.frame = Some((w, h, rgba)); | ||
| 126 | + } | ||
| 127 | + self.session = Some(session); | ||
| 128 | + self.loaded = true; | ||
| 129 | + } | ||
| 130 | + Err(e) => { | ||
| 131 | + self.unsupported = true; | ||
| 132 | + self.error = Some(e); | ||
| 133 | + self.loaded = true; | ||
| 134 | + } | ||
| 135 | + } | ||
| 136 | + } | ||
| 137 | + | ||
| 138 | + #[cfg(not(feature = "video"))] | ||
| 139 | + { | ||
| 140 | + let _ = (ctx, bytes); | ||
| 141 | + self.unsupported = true; | ||
| 142 | + self.error = Some("Video decode feature not enabled".into()); | ||
| 143 | + self.loaded = true; | ||
| 144 | + } | ||
| 145 | + } | ||
| 146 | + | ||
| 147 | + pub fn clear(&mut self) { | ||
| 148 | + self.reset(); | ||
| 149 | + } | ||
| 150 | + | ||
| 151 | + fn reset(&mut self) { | ||
| 152 | + #[cfg(feature = "video")] | ||
| 153 | + { | ||
| 154 | + self.session = None; | ||
| 155 | + } | ||
| 156 | + self.texture = None; | ||
| 157 | + self.tex_size = (0, 0); | ||
| 158 | + self.playing = false; | ||
| 159 | + self.play_started = None; | ||
| 160 | + self.paused_at = 0.0; | ||
| 161 | + self.error = None; | ||
| 162 | + self.unsupported = false; | ||
| 163 | + self.loaded = false; | ||
| 164 | + self.content_id = 0; | ||
| 165 | + } | ||
| 166 | + | ||
| 167 | + fn upload_frame(&mut self, ctx: &egui::Context, w: u32, h: u32, rgba: &[u8]) { | ||
| 168 | + let color = ColorImage::from_rgba_unmultiplied([w as usize, h as usize], rgba); | ||
| 169 | + match &mut self.texture { | ||
| 170 | + Some(tex) if self.tex_size == (w, h) => { | ||
| 171 | + tex.set(color, TextureOptions::LINEAR); | ||
| 172 | + } | ||
| 173 | + _ => { | ||
| 174 | + let name = format!("vidya-video-{}", self.content_id); | ||
| 175 | + self.texture = Some(ctx.load_texture(name, color, TextureOptions::LINEAR)); | ||
| 176 | + self.tex_size = (w, h); | ||
| 177 | + } | ||
| 178 | + } | ||
| 179 | + } | ||
| 180 | + | ||
| 181 | + fn media_time(&self) -> f64 { | ||
| 182 | + if self.playing { | ||
| 183 | + let started = self.play_started.unwrap_or_else(Instant::now); | ||
| 184 | + self.paused_at + started.elapsed().as_secs_f64() | ||
| 185 | + } else { | ||
| 186 | + self.paused_at | ||
| 187 | + } | ||
| 188 | + } | ||
| 189 | + | ||
| 190 | + fn toggle_play(&mut self, ctx: &egui::Context) { | ||
| 191 | + if self.unsupported { | ||
| 192 | + return; | ||
| 193 | + } | ||
| 194 | + if self.playing { | ||
| 195 | + self.paused_at = self.media_time(); | ||
| 196 | + self.playing = false; | ||
| 197 | + self.play_started = None; | ||
| 198 | + } else { | ||
| 199 | + #[cfg(feature = "video")] | ||
| 200 | + { | ||
| 201 | + if let Some(session) = self.session.as_ref() { | ||
| 202 | + if self.paused_at >= session.duration.as_secs_f64() { | ||
| 203 | + self.paused_at = 0.0; | ||
| 204 | + } | ||
| 205 | + } | ||
| 206 | + } | ||
| 207 | + self.playing = true; | ||
| 208 | + self.play_started = Some(Instant::now()); | ||
| 209 | + ctx.request_repaint(); | ||
| 210 | + } | ||
| 211 | + } | ||
| 212 | + | ||
| 213 | + #[cfg(feature = "video")] | ||
| 214 | + fn tick_decode(&mut self, ctx: &egui::Context) { | ||
| 215 | + if !self.playing { | ||
| 216 | + return; | ||
| 217 | + } | ||
| 218 | + let t = self.paused_at | ||
| 219 | + + self | ||
| 220 | + .play_started | ||
| 221 | + .map(|s| s.elapsed().as_secs_f64()) | ||
| 222 | + .unwrap_or(0.0); | ||
| 223 | + | ||
| 224 | + let Some(session) = self.session.as_mut() else { | ||
| 225 | + return; | ||
| 226 | + }; | ||
| 227 | + if let Err(e) = session.seek_playhead(t) { | ||
| 228 | + self.error = Some(e); | ||
| 229 | + self.playing = false; | ||
| 230 | + return; | ||
| 231 | + } | ||
| 232 | + let frame = session.frame.clone(); | ||
| 233 | + let ended = session.ended(t); | ||
| 234 | + let duration = session.duration.as_secs_f64(); | ||
| 235 | + | ||
| 236 | + if let Some((w, h, rgba)) = frame { | ||
| 237 | + self.upload_frame(ctx, w, h, &rgba); | ||
| 238 | + } | ||
| 239 | + if ended { | ||
| 240 | + self.playing = false; | ||
| 241 | + self.play_started = None; | ||
| 242 | + self.paused_at = duration; | ||
| 243 | + } else { | ||
| 244 | + ctx.request_repaint(); | ||
| 245 | + } | ||
| 246 | + } | ||
| 247 | +} | ||
| 248 | + | ||
| 249 | +/// Draw a 16:9 (or source-aspect) video surface with play/pause. | ||
| 250 | +/// | ||
| 251 | +/// Call [`VideoPlayerState::load_bytes`] first when media bytes are ready. | ||
| 252 | +pub fn video_player( | ||
| 253 | + ui: &mut Ui, | ||
| 254 | + theme: &Theme, | ||
| 255 | + state: &mut VideoPlayerState, | ||
| 256 | + opts: &VideoPlayerOpts, | ||
| 257 | +) -> (egui::Response, VideoPlayerAction) { | ||
| 258 | + let p = &theme.palette; | ||
| 259 | + let sp = &theme.spacing; | ||
| 260 | + | ||
| 261 | + #[cfg(feature = "video")] | ||
| 262 | + state.tick_decode(ui.ctx()); | ||
| 263 | + | ||
| 264 | + let max_w = ui.available_width().min(opts.max_width).max(120.0); | ||
| 265 | + let (src_w, src_h) = if state.tex_size.0 > 0 && state.tex_size.1 > 0 { | ||
| 266 | + (state.tex_size.0 as f32, state.tex_size.1 as f32) | ||
| 267 | + } else { | ||
| 268 | + (16.0, 9.0) | ||
| 269 | + }; | ||
| 270 | + let height = (max_w * src_h / src_w).min(opts.max_height).max(72.0); | ||
| 271 | + let size = Vec2::new(max_w, height); | ||
| 272 | + | ||
| 273 | + let mut action = VideoPlayerAction::None; | ||
| 274 | + | ||
| 275 | + let frame_resp = egui::Frame::new() | ||
| 276 | + .fill(Color32::from_rgb(12, 12, 14)) | ||
| 277 | + .stroke(Stroke::new(1.0_f32, p.border_soft)) | ||
| 278 | + .corner_radius(sp.radius_sm) | ||
| 279 | + .show(ui, |ui| { | ||
| 280 | + let (rect, _) = ui.allocate_exact_size(size, Sense::hover()); | ||
| 281 | + | ||
| 282 | + if let Some(tex) = state.texture.as_ref() { | ||
| 283 | + egui::Image::new((tex.id(), size)).paint_at(ui, rect); | ||
| 284 | + } else { | ||
| 285 | + ui.painter() | ||
| 286 | + .rect_filled(rect, sp.radius_sm, Color32::from_rgb(18, 18, 22)); | ||
| 287 | + } | ||
| 288 | + | ||
| 289 | + // Dim + play/pause affordance when not playing (or never started). | ||
| 290 | + if !state.playing { | ||
| 291 | + ui.painter().rect_filled( | ||
| 292 | + rect, | ||
| 293 | + sp.radius_sm, | ||
| 294 | + Color32::from_rgba_unmultiplied(0, 0, 0, 48), | ||
| 295 | + ); | ||
| 296 | + let play_r = (height * 0.18).clamp(16.0, 28.0); | ||
| 297 | + let center = rect.center(); | ||
| 298 | + ui.painter() | ||
| 299 | + .circle_filled(center, play_r, p.accent.gamma_multiply(0.92)); | ||
| 300 | + let tri_w = play_r * 0.7; | ||
| 301 | + let tri_h = play_r * 0.85; | ||
| 302 | + let tip = Pos2::new(center.x + tri_w * 0.55, center.y); | ||
| 303 | + let top = Pos2::new(center.x - tri_w * 0.45, center.y - tri_h * 0.5); | ||
| 304 | + let bot = Pos2::new(center.x - tri_w * 0.45, center.y + tri_h * 0.5); | ||
| 305 | + ui.painter().add(egui::Shape::convex_polygon( | ||
| 306 | + vec![tip, bot, top], | ||
| 307 | + Color32::from_rgb(255, 255, 255), | ||
| 308 | + Stroke::NONE, | ||
| 309 | + )); | ||
| 310 | + } | ||
| 311 | + | ||
| 312 | + let footer = if let Some(err) = state.error.as_deref() { | ||
| 313 | + Some(err.to_string()) | ||
| 314 | + } else if state.playing { | ||
| 315 | + Some("Playing…".into()) | ||
| 316 | + } else { | ||
| 317 | + opts.title.clone() | ||
| 318 | + }; | ||
| 319 | + if let Some(name) = footer { | ||
| 320 | + let foot_h = (theme.type_scale.caption + 10.0).min(height * 0.28); | ||
| 321 | + let foot = Rect::from_min_max( | ||
| 322 | + Pos2::new(rect.left(), rect.bottom() - foot_h), | ||
| 323 | + rect.right_bottom(), | ||
| 324 | + ); | ||
| 325 | + let r = sp.radius_sm as u8; | ||
| 326 | + ui.painter().rect_filled( | ||
| 327 | + foot, | ||
| 328 | + egui::CornerRadius { | ||
| 329 | + nw: 0, | ||
| 330 | + ne: 0, | ||
| 331 | + sw: r, | ||
| 332 | + se: r, | ||
| 333 | + }, | ||
| 334 | + Color32::from_rgba_unmultiplied(0, 0, 0, 160), | ||
| 335 | + ); | ||
| 336 | + ui.painter().text( | ||
| 337 | + Pos2::new(foot.left() + 8.0, foot.center().y), | ||
| 338 | + Align2::LEFT_CENTER, | ||
| 339 | + name, | ||
| 340 | + FontId::proportional(theme.type_scale.caption), | ||
| 341 | + Color32::from_rgb(230, 230, 235), | ||
| 342 | + ); | ||
| 343 | + } | ||
| 344 | + }); | ||
| 345 | + | ||
| 346 | + let tip = if state.unsupported { | ||
| 347 | + state | ||
| 348 | + .error | ||
| 349 | + .as_deref() | ||
| 350 | + .unwrap_or("Video not supported — open externally") | ||
| 351 | + } else if state.playing { | ||
| 352 | + "Pause" | ||
| 353 | + } else { | ||
| 354 | + "Play" | ||
| 355 | + }; | ||
| 356 | + | ||
| 357 | + // Same pattern as sleek's OG / fallback cards: interact on the frame rect | ||
| 358 | + // so parent bubble layout cannot swallow the click. | ||
| 359 | + let resp = ui | ||
| 360 | + .interact( | ||
| 361 | + frame_resp.response.rect, | ||
| 362 | + ui.id().with("vidya_video").with(state.content_id), | ||
| 363 | + Sense::click(), | ||
| 364 | + ) | ||
| 365 | + .on_hover_text(tip) | ||
| 366 | + .on_hover_cursor(CursorIcon::PointingHand); | ||
| 367 | + | ||
| 368 | + if resp.clicked() { | ||
| 369 | + if state.unsupported { | ||
| 370 | + if opts.open_url_on_unsupported.is_some() { | ||
| 371 | + action = VideoPlayerAction::OpenExternally; | ||
| 372 | + } | ||
| 373 | + } else { | ||
| 374 | + state.toggle_play(ui.ctx()); | ||
| 375 | + } | ||
| 376 | + } | ||
| 377 | + | ||
| 378 | + (resp, action) | ||
| 379 | +} | ||
added
crates/vidya-core/src/video/avcc.rs +154 -0 | new file mode 100644 | ||
| @@ -0,0 +1,154 @@ | ||
| 1 | +//! AVCC (length-prefixed) → Annex B converter for H.264-in-MP4. | |
| 2 | +//! | |
| 3 | +//! Adapted from the `openh264` crate's `examples/mp4` helper (BSD-2-Clause). | |
| 4 | + | |
| 5 | +use mp4::Mp4Track; | |
| 6 | + | |
| 7 | +/// Network abstraction layer type for an H.264 NAL unit. | |
| 8 | +#[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
| 9 | +enum NalType { | |
| 10 | + Unspecified = 0, | |
| 11 | + Slice = 1, | |
| 12 | + IdrSlice = 5, | |
| 13 | + Sps = 7, | |
| 14 | + Pps = 8, | |
| 15 | + Other, | |
| 16 | +} | |
| 17 | + | |
| 18 | +impl From<u8> for NalType { | |
| 19 | + fn from(value: u8) -> Self { | |
| 20 | + match value & 0x1F { | |
| 21 | + 0 => Self::Unspecified, | |
| 22 | + 1 => Self::Slice, | |
| 23 | + 5 => Self::IdrSlice, | |
| 24 | + 7 => Self::Sps, | |
| 25 | + 8 => Self::Pps, | |
| 26 | + _ => Self::Other, | |
| 27 | + } | |
| 28 | + } | |
| 29 | +} | |
| 30 | + | |
| 31 | +struct NalUnit<'a> { | |
| 32 | + nal_type: NalType, | |
| 33 | + bytes: &'a [u8], | |
| 34 | +} | |
| 35 | + | |
| 36 | +impl<'a> NalUnit<'a> { | |
| 37 | + fn from_stream(mut stream: &'a [u8], length_size: u8) -> Option<(Self, &'a [u8])> { | |
| 38 | + if stream.len() < length_size as usize { | |
| 39 | + return None; | |
| 40 | + } | |
| 41 | + let mut nal_size = 0u32; | |
| 42 | + for _ in 0..length_size { | |
| 43 | + nal_size = (nal_size << 8) | u32::from(stream[0]); | |
| 44 | + stream = &stream[1..]; | |
| 45 | + } | |
| 46 | + if nal_size == 0 || stream.len() < nal_size as usize { | |
| 47 | + return None; | |
| 48 | + } | |
| 49 | + let packet = &stream[..nal_size as usize]; | |
| 50 | + let nal_type = NalType::from(packet[0]); | |
| 51 | + let unit = NalUnit { | |
| 52 | + nal_type, | |
| 53 | + bytes: packet, | |
| 54 | + }; | |
| 55 | + stream = &stream[nal_size as usize..]; | |
| 56 | + Some((unit, stream)) | |
| 57 | + } | |
| 58 | +} | |
| 59 | + | |
| 60 | +/// Convert MP4/AVCC length-prefixed NAL units to Annex B for OpenH264. | |
| 61 | +/// | |
| 62 | +/// Also injects SPS/PPS from the `avcC` box when an IDR arrives without them. | |
| 63 | +pub struct Mp4BitstreamConverter { | |
| 64 | + length_size: u8, | |
| 65 | + sps: Vec<Vec<u8>>, | |
| 66 | + pps: Vec<Vec<u8>>, | |
| 67 | + new_idr: bool, | |
| 68 | + sps_seen: bool, | |
| 69 | + pps_seen: bool, | |
| 70 | +} | |
| 71 | + | |
| 72 | +impl Mp4BitstreamConverter { | |
| 73 | + pub fn for_mp4_track(track: &Mp4Track) -> Result<Self, String> { | |
| 74 | + let avcc_config = &track | |
| 75 | + .trak | |
| 76 | + .mdia | |
| 77 | + .minf | |
| 78 | + .stbl | |
| 79 | + .stsd | |
| 80 | + .avc1 | |
| 81 | + .as_ref() | |
| 82 | + .ok_or_else(|| "Track does not contain AVC1/avcC config".to_string())? | |
| 83 | + .avcc; | |
| 84 | + | |
| 85 | + Ok(Self { | |
| 86 | + length_size: avcc_config.length_size_minus_one + 1, | |
| 87 | + sps: avcc_config | |
| 88 | + .sequence_parameter_sets | |
| 89 | + .iter() | |
| 90 | + .cloned() | |
| 91 | + .map(|v| v.bytes) | |
| 92 | + .collect(), | |
| 93 | + pps: avcc_config | |
| 94 | + .picture_parameter_sets | |
| 95 | + .iter() | |
| 96 | + .cloned() | |
| 97 | + .map(|v| v.bytes) | |
| 98 | + .collect(), | |
| 99 | + new_idr: true, | |
| 100 | + sps_seen: false, | |
| 101 | + pps_seen: false, | |
| 102 | + }) | |
| 103 | + } | |
| 104 | + | |
| 105 | + pub fn convert_packet(&mut self, packet: &[u8], out: &mut Vec<u8>) { | |
| 106 | + let mut stream = packet; | |
| 107 | + out.clear(); | |
| 108 | + | |
| 109 | + while !stream.is_empty() { | |
| 110 | + let Some((unit, remaining_stream)) = NalUnit::from_stream(stream, self.length_size) | |
| 111 | + else { | |
| 112 | + break; | |
| 113 | + }; | |
| 114 | + stream = remaining_stream; | |
| 115 | + | |
| 116 | + match unit.nal_type { | |
| 117 | + NalType::Sps => self.sps_seen = true, | |
| 118 | + NalType::Pps => self.pps_seen = true, | |
| 119 | + NalType::IdrSlice => { | |
| 120 | + if !self.new_idr && unit.bytes.len() > 1 && unit.bytes[1] & 0x80 != 0 { | |
| 121 | + self.new_idr = true; | |
| 122 | + } | |
| 123 | + if self.new_idr && !self.sps_seen && !self.pps_seen { | |
| 124 | + self.new_idr = false; | |
| 125 | + for sps in &self.sps { | |
| 126 | + out.extend([0, 0, 1]); | |
| 127 | + out.extend(sps); | |
| 128 | + } | |
| 129 | + for pps in &self.pps { | |
| 130 | + out.extend([0, 0, 1]); | |
| 131 | + out.extend(pps); | |
| 132 | + } | |
| 133 | + } | |
| 134 | + if self.new_idr && self.sps_seen && !self.pps_seen { | |
| 135 | + for pps in &self.pps { | |
| 136 | + out.extend([0, 0, 1]); | |
| 137 | + out.extend(pps); | |
| 138 | + } | |
| 139 | + } | |
| 140 | + } | |
| 141 | + _ => {} | |
| 142 | + } | |
| 143 | + | |
| 144 | + out.extend([0, 0, 1]); | |
| 145 | + out.extend(unit.bytes); | |
| 146 | + | |
| 147 | + if !self.new_idr && unit.nal_type == NalType::Slice { | |
| 148 | + self.new_idr = true; | |
| 149 | + self.sps_seen = false; | |
| 150 | + self.pps_seen = false; | |
| 151 | + } | |
| 152 | + } | |
| 153 | + } | |
| 154 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,154 @@ | |||
| 1 | +//! AVCC (length-prefixed) → Annex B converter for H.264-in-MP4. | ||
| 2 | +//! | ||
| 3 | +//! Adapted from the `openh264` crate's `examples/mp4` helper (BSD-2-Clause). | ||
| 4 | + | ||
| 5 | +use mp4::Mp4Track; | ||
| 6 | + | ||
| 7 | +/// Network abstraction layer type for an H.264 NAL unit. | ||
| 8 | +#[derive(Debug, Clone, Copy, PartialEq, Eq)] | ||
| 9 | +enum NalType { | ||
| 10 | + Unspecified = 0, | ||
| 11 | + Slice = 1, | ||
| 12 | + IdrSlice = 5, | ||
| 13 | + Sps = 7, | ||
| 14 | + Pps = 8, | ||
| 15 | + Other, | ||
| 16 | +} | ||
| 17 | + | ||
| 18 | +impl From<u8> for NalType { | ||
| 19 | + fn from(value: u8) -> Self { | ||
| 20 | + match value & 0x1F { | ||
| 21 | + 0 => Self::Unspecified, | ||
| 22 | + 1 => Self::Slice, | ||
| 23 | + 5 => Self::IdrSlice, | ||
| 24 | + 7 => Self::Sps, | ||
| 25 | + 8 => Self::Pps, | ||
| 26 | + _ => Self::Other, | ||
| 27 | + } | ||
| 28 | + } | ||
| 29 | +} | ||
| 30 | + | ||
| 31 | +struct NalUnit<'a> { | ||
| 32 | + nal_type: NalType, | ||
| 33 | + bytes: &'a [u8], | ||
| 34 | +} | ||
| 35 | + | ||
| 36 | +impl<'a> NalUnit<'a> { | ||
| 37 | + fn from_stream(mut stream: &'a [u8], length_size: u8) -> Option<(Self, &'a [u8])> { | ||
| 38 | + if stream.len() < length_size as usize { | ||
| 39 | + return None; | ||
| 40 | + } | ||
| 41 | + let mut nal_size = 0u32; | ||
| 42 | + for _ in 0..length_size { | ||
| 43 | + nal_size = (nal_size << 8) | u32::from(stream[0]); | ||
| 44 | + stream = &stream[1..]; | ||
| 45 | + } | ||
| 46 | + if nal_size == 0 || stream.len() < nal_size as usize { | ||
| 47 | + return None; | ||
| 48 | + } | ||
| 49 | + let packet = &stream[..nal_size as usize]; | ||
| 50 | + let nal_type = NalType::from(packet[0]); | ||
| 51 | + let unit = NalUnit { | ||
| 52 | + nal_type, | ||
| 53 | + bytes: packet, | ||
| 54 | + }; | ||
| 55 | + stream = &stream[nal_size as usize..]; | ||
| 56 | + Some((unit, stream)) | ||
| 57 | + } | ||
| 58 | +} | ||
| 59 | + | ||
| 60 | +/// Convert MP4/AVCC length-prefixed NAL units to Annex B for OpenH264. | ||
| 61 | +/// | ||
| 62 | +/// Also injects SPS/PPS from the `avcC` box when an IDR arrives without them. | ||
| 63 | +pub struct Mp4BitstreamConverter { | ||
| 64 | + length_size: u8, | ||
| 65 | + sps: Vec<Vec<u8>>, | ||
| 66 | + pps: Vec<Vec<u8>>, | ||
| 67 | + new_idr: bool, | ||
| 68 | + sps_seen: bool, | ||
| 69 | + pps_seen: bool, | ||
| 70 | +} | ||
| 71 | + | ||
| 72 | +impl Mp4BitstreamConverter { | ||
| 73 | + pub fn for_mp4_track(track: &Mp4Track) -> Result<Self, String> { | ||
| 74 | + let avcc_config = &track | ||
| 75 | + .trak | ||
| 76 | + .mdia | ||
| 77 | + .minf | ||
| 78 | + .stbl | ||
| 79 | + .stsd | ||
| 80 | + .avc1 | ||
| 81 | + .as_ref() | ||
| 82 | + .ok_or_else(|| "Track does not contain AVC1/avcC config".to_string())? | ||
| 83 | + .avcc; | ||
| 84 | + | ||
| 85 | + Ok(Self { | ||
| 86 | + length_size: avcc_config.length_size_minus_one + 1, | ||
| 87 | + sps: avcc_config | ||
| 88 | + .sequence_parameter_sets | ||
| 89 | + .iter() | ||
| 90 | + .cloned() | ||
| 91 | + .map(|v| v.bytes) | ||
| 92 | + .collect(), | ||
| 93 | + pps: avcc_config | ||
| 94 | + .picture_parameter_sets | ||
| 95 | + .iter() | ||
| 96 | + .cloned() | ||
| 97 | + .map(|v| v.bytes) | ||
| 98 | + .collect(), | ||
| 99 | + new_idr: true, | ||
| 100 | + sps_seen: false, | ||
| 101 | + pps_seen: false, | ||
| 102 | + }) | ||
| 103 | + } | ||
| 104 | + | ||
| 105 | + pub fn convert_packet(&mut self, packet: &[u8], out: &mut Vec<u8>) { | ||
| 106 | + let mut stream = packet; | ||
| 107 | + out.clear(); | ||
| 108 | + | ||
| 109 | + while !stream.is_empty() { | ||
| 110 | + let Some((unit, remaining_stream)) = NalUnit::from_stream(stream, self.length_size) | ||
| 111 | + else { | ||
| 112 | + break; | ||
| 113 | + }; | ||
| 114 | + stream = remaining_stream; | ||
| 115 | + | ||
| 116 | + match unit.nal_type { | ||
| 117 | + NalType::Sps => self.sps_seen = true, | ||
| 118 | + NalType::Pps => self.pps_seen = true, | ||
| 119 | + NalType::IdrSlice => { | ||
| 120 | + if !self.new_idr && unit.bytes.len() > 1 && unit.bytes[1] & 0x80 != 0 { | ||
| 121 | + self.new_idr = true; | ||
| 122 | + } | ||
| 123 | + if self.new_idr && !self.sps_seen && !self.pps_seen { | ||
| 124 | + self.new_idr = false; | ||
| 125 | + for sps in &self.sps { | ||
| 126 | + out.extend([0, 0, 1]); | ||
| 127 | + out.extend(sps); | ||
| 128 | + } | ||
| 129 | + for pps in &self.pps { | ||
| 130 | + out.extend([0, 0, 1]); | ||
| 131 | + out.extend(pps); | ||
| 132 | + } | ||
| 133 | + } | ||
| 134 | + if self.new_idr && self.sps_seen && !self.pps_seen { | ||
| 135 | + for pps in &self.pps { | ||
| 136 | + out.extend([0, 0, 1]); | ||
| 137 | + out.extend(pps); | ||
| 138 | + } | ||
| 139 | + } | ||
| 140 | + } | ||
| 141 | + _ => {} | ||
| 142 | + } | ||
| 143 | + | ||
| 144 | + out.extend([0, 0, 1]); | ||
| 145 | + out.extend(unit.bytes); | ||
| 146 | + | ||
| 147 | + if !self.new_idr && unit.nal_type == NalType::Slice { | ||
| 148 | + self.new_idr = true; | ||
| 149 | + self.sps_seen = false; | ||
| 150 | + self.pps_seen = false; | ||
| 151 | + } | ||
| 152 | + } | ||
| 153 | + } | ||
| 154 | +} | ||
added
crates/vidya-core/src/video/decode.rs +252 -0 | new file mode 100644 | ||
| @@ -0,0 +1,252 @@ | ||
| 1 | +//! H.264-in-MP4 decode session (openh264 + mp4 demux). | |
| 2 | + | |
| 3 | +use std::io::Cursor; | |
| 4 | +use std::sync::Arc; | |
| 5 | +use std::time::Duration; | |
| 6 | + | |
| 7 | +use openh264::decoder::{Decoder, DecoderConfig, Flush}; | |
| 8 | +use openh264::formats::YUVSource; | |
| 9 | +use openh264::OpenH264API; | |
| 10 | + | |
| 11 | +use super::avcc::Mp4BitstreamConverter; | |
| 12 | + | |
| 13 | +/// Decoded poster / playback session for one MP4 byte buffer. | |
| 14 | +pub struct DecodeSession { | |
| 15 | + mp4: mp4::Mp4Reader<Cursor<Arc<[u8]>>>, | |
| 16 | + track_id: u32, | |
| 17 | + sample_count: u32, | |
| 18 | + timescale: u32, | |
| 19 | + converter: Mp4BitstreamConverter, | |
| 20 | + decoder: Decoder, | |
| 21 | + pub width: u32, | |
| 22 | + pub height: u32, | |
| 23 | + /// Presentation time (seconds) for sample index `i` (0-based). | |
| 24 | + sample_pts: Vec<f64>, | |
| 25 | + pub duration: Duration, | |
| 26 | + /// 1-based sample id last successfully fed to the decoder. | |
| 27 | + next_sample: u32, | |
| 28 | + buffer: Vec<u8>, | |
| 29 | + rgba: Vec<u8>, | |
| 30 | + /// Latest decoded frame (RGBA). | |
| 31 | + pub frame: Option<(u32, u32, Vec<u8>)>, | |
| 32 | +} | |
| 33 | + | |
| 34 | +impl DecodeSession { | |
| 35 | + pub fn open(bytes: Arc<[u8]>) -> Result<Self, String> { | |
| 36 | + if bytes.len() < 12 { | |
| 37 | + return Err("Video too small".into()); | |
| 38 | + } | |
| 39 | + // Reject obvious non-MP4 (WebM starts with EBML 0x1A45DFA3). | |
| 40 | + if bytes.starts_with(&[0x1A, 0x45, 0xDF, 0xA3]) { | |
| 41 | + return Err("WebM is not supported yet (H.264 MP4 only)".into()); | |
| 42 | + } | |
| 43 | + | |
| 44 | + let size = bytes.len() as u64; | |
| 45 | + let mut mp4 = mp4::Mp4Reader::read_header(Cursor::new(bytes), size) | |
| 46 | + .map_err(|e| format!("MP4 header: {e}"))?; | |
| 47 | + | |
| 48 | + let (track_id, width, height, sample_count, timescale, duration_secs) = { | |
| 49 | + let track = mp4 | |
| 50 | + .tracks() | |
| 51 | + .iter() | |
| 52 | + .find(|(_, t)| matches!(t.track_type(), Ok(mp4::TrackType::Video))) | |
| 53 | + .or_else(|| { | |
| 54 | + mp4.tracks() | |
| 55 | + .iter() | |
| 56 | + .find(|(_, t)| matches!(t.media_type(), Ok(mp4::MediaType::H264))) | |
| 57 | + }) | |
| 58 | + .map(|(_, t)| t) | |
| 59 | + .ok_or_else(|| "No video track in MP4".to_string())?; | |
| 60 | + | |
| 61 | + match track.media_type() { | |
| 62 | + Ok(mp4::MediaType::H264) => {} | |
| 63 | + Ok(other) => { | |
| 64 | + return Err(format!("Unsupported video codec in MP4: {other:?}")); | |
| 65 | + } | |
| 66 | + Err(e) => return Err(format!("Track media type: {e}")), | |
| 67 | + } | |
| 68 | + | |
| 69 | + let track_id = track.track_id(); | |
| 70 | + let width = u32::from(track.width()); | |
| 71 | + let height = u32::from(track.height()); | |
| 72 | + let sample_count = track.sample_count(); | |
| 73 | + let timescale = track.timescale().max(1); | |
| 74 | + let duration_secs = track.duration(); | |
| 75 | + ( | |
| 76 | + track_id, | |
| 77 | + width, | |
| 78 | + height, | |
| 79 | + sample_count, | |
| 80 | + timescale, | |
| 81 | + duration_secs, | |
| 82 | + ) | |
| 83 | + }; | |
| 84 | + | |
| 85 | + if sample_count == 0 || width == 0 || height == 0 { | |
| 86 | + return Err("Empty video track".into()); | |
| 87 | + } | |
| 88 | + | |
| 89 | + let converter = { | |
| 90 | + let track = mp4 | |
| 91 | + .tracks() | |
| 92 | + .get(&track_id) | |
| 93 | + .ok_or_else(|| "Missing track after probe".to_string())?; | |
| 94 | + Mp4BitstreamConverter::for_mp4_track(track)? | |
| 95 | + }; | |
| 96 | + | |
| 97 | + let decoder_options = DecoderConfig::new().flush_after_decode(Flush::NoFlush); | |
| 98 | + let decoder = Decoder::with_api_config(OpenH264API::from_source(), decoder_options) | |
| 99 | + .map_err(|e| format!("OpenH264 init: {e}"))?; | |
| 100 | + | |
| 101 | + let mut sample_pts = Vec::with_capacity(sample_count as usize); | |
| 102 | + for i in 1..=sample_count { | |
| 103 | + if let Ok(Some(sample)) = mp4.read_sample(track_id, i) { | |
| 104 | + sample_pts.push(sample.start_time as f64 / timescale as f64); | |
| 105 | + } else { | |
| 106 | + sample_pts.push(sample_pts.last().copied().unwrap_or(0.0)); | |
| 107 | + } | |
| 108 | + } | |
| 109 | + | |
| 110 | + let mut session = Self { | |
| 111 | + mp4, | |
| 112 | + track_id, | |
| 113 | + sample_count, | |
| 114 | + timescale, | |
| 115 | + converter, | |
| 116 | + decoder, | |
| 117 | + width, | |
| 118 | + height, | |
| 119 | + sample_pts, | |
| 120 | + duration: duration_secs, | |
| 121 | + next_sample: 1, | |
| 122 | + buffer: Vec::new(), | |
| 123 | + rgba: vec![0; (width as usize) * (height as usize) * 4], | |
| 124 | + frame: None, | |
| 125 | + }; | |
| 126 | + | |
| 127 | + // Decode until we have a poster frame. | |
| 128 | + session.decode_until_frame()?; | |
| 129 | + if session.frame.is_none() { | |
| 130 | + return Err("Could not decode a video frame".into()); | |
| 131 | + } | |
| 132 | + Ok(session) | |
| 133 | + } | |
| 134 | + | |
| 135 | + /// Advance decoding so a frame at or past `t` seconds is available. | |
| 136 | + pub fn seek_playhead(&mut self, t: f64) -> Result<(), String> { | |
| 137 | + let t = t.clamp(0.0, self.duration.as_secs_f64().max(0.0)); | |
| 138 | + // Find the first sample whose PTS is >= t (or the last sample). | |
| 139 | + let mut target = self.sample_count; | |
| 140 | + for (idx, pts) in self.sample_pts.iter().enumerate() { | |
| 141 | + if *pts + f64::EPSILON >= t { | |
| 142 | + target = (idx as u32) + 1; | |
| 143 | + break; | |
| 144 | + } | |
| 145 | + } | |
| 146 | + if target < self.next_sample { | |
| 147 | + // Need to restart decoder to go backwards / loop. | |
| 148 | + self.reset_decoder()?; | |
| 149 | + } | |
| 150 | + while self.next_sample <= target { | |
| 151 | + if !self.feed_next_sample()? { | |
| 152 | + break; | |
| 153 | + } | |
| 154 | + } | |
| 155 | + Ok(()) | |
| 156 | + } | |
| 157 | + | |
| 158 | + fn reset_decoder(&mut self) -> Result<(), String> { | |
| 159 | + let decoder_options = DecoderConfig::new().flush_after_decode(Flush::NoFlush); | |
| 160 | + self.decoder = Decoder::with_api_config(OpenH264API::from_source(), decoder_options) | |
| 161 | + .map_err(|e| format!("OpenH264 re-init: {e}"))?; | |
| 162 | + // Rebuild converter (SPS inject state). | |
| 163 | + let track = self | |
| 164 | + .mp4 | |
| 165 | + .tracks() | |
| 166 | + .get(&self.track_id) | |
| 167 | + .ok_or_else(|| "Missing track".to_string())?; | |
| 168 | + self.converter = Mp4BitstreamConverter::for_mp4_track(track)?; | |
| 169 | + self.next_sample = 1; | |
| 170 | + Ok(()) | |
| 171 | + } | |
| 172 | + | |
| 173 | + fn decode_until_frame(&mut self) -> Result<(), String> { | |
| 174 | + while self.frame.is_none() && self.next_sample <= self.sample_count { | |
| 175 | + self.feed_next_sample()?; | |
| 176 | + } | |
| 177 | + Ok(()) | |
| 178 | + } | |
| 179 | + | |
| 180 | + /// Feed one sample. Returns false when the stream is exhausted. | |
| 181 | + fn feed_next_sample(&mut self) -> Result<bool, String> { | |
| 182 | + if self.next_sample > self.sample_count { | |
| 183 | + return Ok(false); | |
| 184 | + } | |
| 185 | + let sample_id = self.next_sample; | |
| 186 | + self.next_sample += 1; | |
| 187 | + | |
| 188 | + let Some(sample) = self | |
| 189 | + .mp4 | |
| 190 | + .read_sample(self.track_id, sample_id) | |
| 191 | + .map_err(|e| format!("Read sample: {e}"))? | |
| 192 | + else { | |
| 193 | + return Ok(true); | |
| 194 | + }; | |
| 195 | + | |
| 196 | + self.converter | |
| 197 | + .convert_packet(&sample.bytes, &mut self.buffer); | |
| 198 | + if self.buffer.is_empty() { | |
| 199 | + return Ok(true); | |
| 200 | + } | |
| 201 | + | |
| 202 | + match self.decoder.decode(&self.buffer) { | |
| 203 | + Ok(Some(yuv)) => { | |
| 204 | + let (w, h) = yuv.dimensions(); | |
| 205 | + let need = w * h * 4; | |
| 206 | + if self.rgba.len() != need { | |
| 207 | + self.rgba.resize(need, 0); | |
| 208 | + self.width = w as u32; | |
| 209 | + self.height = h as u32; | |
| 210 | + } | |
| 211 | + yuv.write_rgba8(&mut self.rgba); | |
| 212 | + self.frame = Some((self.width, self.height, self.rgba.clone())); | |
| 213 | + } | |
| 214 | + Ok(None) => {} | |
| 215 | + Err(e) => { | |
| 216 | + // Soft-fail individual samples (B-frames / corruption). | |
| 217 | + let _ = e; | |
| 218 | + } | |
| 219 | + } | |
| 220 | + Ok(true) | |
| 221 | + } | |
| 222 | + | |
| 223 | + pub fn ended(&self, t: f64) -> bool { | |
| 224 | + t >= self.duration.as_secs_f64() && self.next_sample > self.sample_count | |
| 225 | + } | |
| 226 | + | |
| 227 | + #[allow(dead_code)] | |
| 228 | + pub fn timescale(&self) -> u32 { | |
| 229 | + self.timescale | |
| 230 | + } | |
| 231 | +} | |
| 232 | + | |
| 233 | +#[cfg(all(test, feature = "video"))] | |
| 234 | +mod tests { | |
| 235 | + use super::*; | |
| 236 | + use std::path::PathBuf; | |
| 237 | + | |
| 238 | + #[test] | |
| 239 | + fn decodes_sample_h264_mp4() { | |
| 240 | + let path = PathBuf::from("/tmp/sleek-test.mp4"); | |
| 241 | + if !path.exists() { | |
| 242 | + eprintln!("skip: missing {}", path.display()); | |
| 243 | + return; | |
| 244 | + } | |
| 245 | + let bytes: Arc<[u8]> = std::fs::read(&path).unwrap().into(); | |
| 246 | + let mut session = DecodeSession::open(bytes).expect("open"); | |
| 247 | + assert!(session.width > 0 && session.height > 0); | |
| 248 | + assert!(session.frame.is_some()); | |
| 249 | + session.seek_playhead(0.5).expect("seek"); | |
| 250 | + assert!(session.frame.is_some()); | |
| 251 | + } | |
| 252 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,252 @@ | |||
| 1 | +//! H.264-in-MP4 decode session (openh264 + mp4 demux). | ||
| 2 | + | ||
| 3 | +use std::io::Cursor; | ||
| 4 | +use std::sync::Arc; | ||
| 5 | +use std::time::Duration; | ||
| 6 | + | ||
| 7 | +use openh264::decoder::{Decoder, DecoderConfig, Flush}; | ||
| 8 | +use openh264::formats::YUVSource; | ||
| 9 | +use openh264::OpenH264API; | ||
| 10 | + | ||
| 11 | +use super::avcc::Mp4BitstreamConverter; | ||
| 12 | + | ||
| 13 | +/// Decoded poster / playback session for one MP4 byte buffer. | ||
| 14 | +pub struct DecodeSession { | ||
| 15 | + mp4: mp4::Mp4Reader<Cursor<Arc<[u8]>>>, | ||
| 16 | + track_id: u32, | ||
| 17 | + sample_count: u32, | ||
| 18 | + timescale: u32, | ||
| 19 | + converter: Mp4BitstreamConverter, | ||
| 20 | + decoder: Decoder, | ||
| 21 | + pub width: u32, | ||
| 22 | + pub height: u32, | ||
| 23 | + /// Presentation time (seconds) for sample index `i` (0-based). | ||
| 24 | + sample_pts: Vec<f64>, | ||
| 25 | + pub duration: Duration, | ||
| 26 | + /// 1-based sample id last successfully fed to the decoder. | ||
| 27 | + next_sample: u32, | ||
| 28 | + buffer: Vec<u8>, | ||
| 29 | + rgba: Vec<u8>, | ||
| 30 | + /// Latest decoded frame (RGBA). | ||
| 31 | + pub frame: Option<(u32, u32, Vec<u8>)>, | ||
| 32 | +} | ||
| 33 | + | ||
| 34 | +impl DecodeSession { | ||
| 35 | + pub fn open(bytes: Arc<[u8]>) -> Result<Self, String> { | ||
| 36 | + if bytes.len() < 12 { | ||
| 37 | + return Err("Video too small".into()); | ||
| 38 | + } | ||
| 39 | + // Reject obvious non-MP4 (WebM starts with EBML 0x1A45DFA3). | ||
| 40 | + if bytes.starts_with(&[0x1A, 0x45, 0xDF, 0xA3]) { | ||
| 41 | + return Err("WebM is not supported yet (H.264 MP4 only)".into()); | ||
| 42 | + } | ||
| 43 | + | ||
| 44 | + let size = bytes.len() as u64; | ||
| 45 | + let mut mp4 = mp4::Mp4Reader::read_header(Cursor::new(bytes), size) | ||
| 46 | + .map_err(|e| format!("MP4 header: {e}"))?; | ||
| 47 | + | ||
| 48 | + let (track_id, width, height, sample_count, timescale, duration_secs) = { | ||
| 49 | + let track = mp4 | ||
| 50 | + .tracks() | ||
| 51 | + .iter() | ||
| 52 | + .find(|(_, t)| matches!(t.track_type(), Ok(mp4::TrackType::Video))) | ||
| 53 | + .or_else(|| { | ||
| 54 | + mp4.tracks() | ||
| 55 | + .iter() | ||
| 56 | + .find(|(_, t)| matches!(t.media_type(), Ok(mp4::MediaType::H264))) | ||
| 57 | + }) | ||
| 58 | + .map(|(_, t)| t) | ||
| 59 | + .ok_or_else(|| "No video track in MP4".to_string())?; | ||
| 60 | + | ||
| 61 | + match track.media_type() { | ||
| 62 | + Ok(mp4::MediaType::H264) => {} | ||
| 63 | + Ok(other) => { | ||
| 64 | + return Err(format!("Unsupported video codec in MP4: {other:?}")); | ||
| 65 | + } | ||
| 66 | + Err(e) => return Err(format!("Track media type: {e}")), | ||
| 67 | + } | ||
| 68 | + | ||
| 69 | + let track_id = track.track_id(); | ||
| 70 | + let width = u32::from(track.width()); | ||
| 71 | + let height = u32::from(track.height()); | ||
| 72 | + let sample_count = track.sample_count(); | ||
| 73 | + let timescale = track.timescale().max(1); | ||
| 74 | + let duration_secs = track.duration(); | ||
| 75 | + ( | ||
| 76 | + track_id, | ||
| 77 | + width, | ||
| 78 | + height, | ||
| 79 | + sample_count, | ||
| 80 | + timescale, | ||
| 81 | + duration_secs, | ||
| 82 | + ) | ||
| 83 | + }; | ||
| 84 | + | ||
| 85 | + if sample_count == 0 || width == 0 || height == 0 { | ||
| 86 | + return Err("Empty video track".into()); | ||
| 87 | + } | ||
| 88 | + | ||
| 89 | + let converter = { | ||
| 90 | + let track = mp4 | ||
| 91 | + .tracks() | ||
| 92 | + .get(&track_id) | ||
| 93 | + .ok_or_else(|| "Missing track after probe".to_string())?; | ||
| 94 | + Mp4BitstreamConverter::for_mp4_track(track)? | ||
| 95 | + }; | ||
| 96 | + | ||
| 97 | + let decoder_options = DecoderConfig::new().flush_after_decode(Flush::NoFlush); | ||
| 98 | + let decoder = Decoder::with_api_config(OpenH264API::from_source(), decoder_options) | ||
| 99 | + .map_err(|e| format!("OpenH264 init: {e}"))?; | ||
| 100 | + | ||
| 101 | + let mut sample_pts = Vec::with_capacity(sample_count as usize); | ||
| 102 | + for i in 1..=sample_count { | ||
| 103 | + if let Ok(Some(sample)) = mp4.read_sample(track_id, i) { | ||
| 104 | + sample_pts.push(sample.start_time as f64 / timescale as f64); | ||
| 105 | + } else { | ||
| 106 | + sample_pts.push(sample_pts.last().copied().unwrap_or(0.0)); | ||
| 107 | + } | ||
| 108 | + } | ||
| 109 | + | ||
| 110 | + let mut session = Self { | ||
| 111 | + mp4, | ||
| 112 | + track_id, | ||
| 113 | + sample_count, | ||
| 114 | + timescale, | ||
| 115 | + converter, | ||
| 116 | + decoder, | ||
| 117 | + width, | ||
| 118 | + height, | ||
| 119 | + sample_pts, | ||
| 120 | + duration: duration_secs, | ||
| 121 | + next_sample: 1, | ||
| 122 | + buffer: Vec::new(), | ||
| 123 | + rgba: vec![0; (width as usize) * (height as usize) * 4], | ||
| 124 | + frame: None, | ||
| 125 | + }; | ||
| 126 | + | ||
| 127 | + // Decode until we have a poster frame. | ||
| 128 | + session.decode_until_frame()?; | ||
| 129 | + if session.frame.is_none() { | ||
| 130 | + return Err("Could not decode a video frame".into()); | ||
| 131 | + } | ||
| 132 | + Ok(session) | ||
| 133 | + } | ||
| 134 | + | ||
| 135 | + /// Advance decoding so a frame at or past `t` seconds is available. | ||
| 136 | + pub fn seek_playhead(&mut self, t: f64) -> Result<(), String> { | ||
| 137 | + let t = t.clamp(0.0, self.duration.as_secs_f64().max(0.0)); | ||
| 138 | + // Find the first sample whose PTS is >= t (or the last sample). | ||
| 139 | + let mut target = self.sample_count; | ||
| 140 | + for (idx, pts) in self.sample_pts.iter().enumerate() { | ||
| 141 | + if *pts + f64::EPSILON >= t { | ||
| 142 | + target = (idx as u32) + 1; | ||
| 143 | + break; | ||
| 144 | + } | ||
| 145 | + } | ||
| 146 | + if target < self.next_sample { | ||
| 147 | + // Need to restart decoder to go backwards / loop. | ||
| 148 | + self.reset_decoder()?; | ||
| 149 | + } | ||
| 150 | + while self.next_sample <= target { | ||
| 151 | + if !self.feed_next_sample()? { | ||
| 152 | + break; | ||
| 153 | + } | ||
| 154 | + } | ||
| 155 | + Ok(()) | ||
| 156 | + } | ||
| 157 | + | ||
| 158 | + fn reset_decoder(&mut self) -> Result<(), String> { | ||
| 159 | + let decoder_options = DecoderConfig::new().flush_after_decode(Flush::NoFlush); | ||
| 160 | + self.decoder = Decoder::with_api_config(OpenH264API::from_source(), decoder_options) | ||
| 161 | + .map_err(|e| format!("OpenH264 re-init: {e}"))?; | ||
| 162 | + // Rebuild converter (SPS inject state). | ||
| 163 | + let track = self | ||
| 164 | + .mp4 | ||
| 165 | + .tracks() | ||
| 166 | + .get(&self.track_id) | ||
| 167 | + .ok_or_else(|| "Missing track".to_string())?; | ||
| 168 | + self.converter = Mp4BitstreamConverter::for_mp4_track(track)?; | ||
| 169 | + self.next_sample = 1; | ||
| 170 | + Ok(()) | ||
| 171 | + } | ||
| 172 | + | ||
| 173 | + fn decode_until_frame(&mut self) -> Result<(), String> { | ||
| 174 | + while self.frame.is_none() && self.next_sample <= self.sample_count { | ||
| 175 | + self.feed_next_sample()?; | ||
| 176 | + } | ||
| 177 | + Ok(()) | ||
| 178 | + } | ||
| 179 | + | ||
| 180 | + /// Feed one sample. Returns false when the stream is exhausted. | ||
| 181 | + fn feed_next_sample(&mut self) -> Result<bool, String> { | ||
| 182 | + if self.next_sample > self.sample_count { | ||
| 183 | + return Ok(false); | ||
| 184 | + } | ||
| 185 | + let sample_id = self.next_sample; | ||
| 186 | + self.next_sample += 1; | ||
| 187 | + | ||
| 188 | + let Some(sample) = self | ||
| 189 | + .mp4 | ||
| 190 | + .read_sample(self.track_id, sample_id) | ||
| 191 | + .map_err(|e| format!("Read sample: {e}"))? | ||
| 192 | + else { | ||
| 193 | + return Ok(true); | ||
| 194 | + }; | ||
| 195 | + | ||
| 196 | + self.converter | ||
| 197 | + .convert_packet(&sample.bytes, &mut self.buffer); | ||
| 198 | + if self.buffer.is_empty() { | ||
| 199 | + return Ok(true); | ||
| 200 | + } | ||
| 201 | + | ||
| 202 | + match self.decoder.decode(&self.buffer) { | ||
| 203 | + Ok(Some(yuv)) => { | ||
| 204 | + let (w, h) = yuv.dimensions(); | ||
| 205 | + let need = w * h * 4; | ||
| 206 | + if self.rgba.len() != need { | ||
| 207 | + self.rgba.resize(need, 0); | ||
| 208 | + self.width = w as u32; | ||
| 209 | + self.height = h as u32; | ||
| 210 | + } | ||
| 211 | + yuv.write_rgba8(&mut self.rgba); | ||
| 212 | + self.frame = Some((self.width, self.height, self.rgba.clone())); | ||
| 213 | + } | ||
| 214 | + Ok(None) => {} | ||
| 215 | + Err(e) => { | ||
| 216 | + // Soft-fail individual samples (B-frames / corruption). | ||
| 217 | + let _ = e; | ||
| 218 | + } | ||
| 219 | + } | ||
| 220 | + Ok(true) | ||
| 221 | + } | ||
| 222 | + | ||
| 223 | + pub fn ended(&self, t: f64) -> bool { | ||
| 224 | + t >= self.duration.as_secs_f64() && self.next_sample > self.sample_count | ||
| 225 | + } | ||
| 226 | + | ||
| 227 | + #[allow(dead_code)] | ||
| 228 | + pub fn timescale(&self) -> u32 { | ||
| 229 | + self.timescale | ||
| 230 | + } | ||
| 231 | +} | ||
| 232 | + | ||
| 233 | +#[cfg(all(test, feature = "video"))] | ||
| 234 | +mod tests { | ||
| 235 | + use super::*; | ||
| 236 | + use std::path::PathBuf; | ||
| 237 | + | ||
| 238 | + #[test] | ||
| 239 | + fn decodes_sample_h264_mp4() { | ||
| 240 | + let path = PathBuf::from("/tmp/sleek-test.mp4"); | ||
| 241 | + if !path.exists() { | ||
| 242 | + eprintln!("skip: missing {}", path.display()); | ||
| 243 | + return; | ||
| 244 | + } | ||
| 245 | + let bytes: Arc<[u8]> = std::fs::read(&path).unwrap().into(); | ||
| 246 | + let mut session = DecodeSession::open(bytes).expect("open"); | ||
| 247 | + assert!(session.width > 0 && session.height > 0); | ||
| 248 | + assert!(session.frame.is_some()); | ||
| 249 | + session.seek_playhead(0.5).expect("seek"); | ||
| 250 | + assert!(session.frame.is_some()); | ||
| 251 | + } | ||
| 252 | +} | ||
added
jolt/glimmer-vidya/.jolt/cpcache/k1019236042.edn +1 -0 | new file mode 100644 | ||
| @@ -0,0 +1 @@ | ||
| 1 | +{:material "1598:{:paths [\"src\"]\n\n ;; glimmer-vidya is a backend for glimmer: glimmer owns the reactive core\n ;; (ratom, component model, reconciler) and knows nothing about any toolkit;\n ;; this project supplies the widgets, painting and the frame loop by way of\n ;; Vidya's retained-tree C ABI, and registers them through glimmer.backend.\n :deps {jolt-lang/glimmer {:git/url \"https://github.com/jolt-lang/glimmer\"\n :git/tag \"v0.1.0\"\n :git/sha \"5581c331c51aff989259b9e8e92ec920fe5e6741\"}}\n\n ;; libvidya, built from ../ffi (the Rust/egui implementation). Only that\n ;; backend implements the tree ABI this project binds — the C/raylib build of\n ;; libvidya exports vidya.h alone, so it is deliberately NOT interchangeable\n ;; here, unlike for ../jolt. Put the built library on the search path:\n ;; just ffi\n ;; LD_LIBRARY_PATH=../build jolt counter\n :jolt/native [{:name \"vidya\"\n :darwin [\"libvidya.dylib\"]\n :linux [\"libvidya.so\"]}]\n\n :aliases {:test {:extra-paths [\"test\"]\n :main-opts [\"-m\" \"glimmer-vidya.test-runner\"]}\n :counter {:extra-paths [\"examples\"]\n :main-opts [\"-m\" \"glimmer-vidya.counter\"]}\n :showcase {:extra-paths [\"examples\"]\n :main-opts [\"-m\" \"glimmer-vidya.showcase\"]}\n :smoke {:extra-paths [\"examples\"]\n :main-opts [\"-m\" \"glimmer-vidya.smoke\"]}}\n\n :tasks {test \"jolt -M:test\"\n counter \"jolt -M:counter\"\n showcase \"jolt -M:showcase\"\n smoke \"jolt -M:smoke\"}}\n|10:::absent::|[:test]|[[jolt-lang/glimmer #:git{:url \"https://github.com/jolt-lang/glimmer\", :tag \"v0.1.0\", :sha \"5581c331c51aff989259b9e8e92ec920fe5e6741\"}]]|[]|[]|v0.7.28|/var/home/nandi/.jolt/gitlibs|||||/var/home/nandi|", :value {:roots ["/var/home/nandi/.jolt/gitlibs/https___github.com_jolt-lang_glimmer/5581c331c51aff989259b9e8e92ec920fe5e6741/src"], :natives [], :prep [], :libs {jolt-lang/glimmer #:git{:url "https://github.com/jolt-lang/glimmer", :tag "v0.1.0", :sha "5581c331c51aff989259b9e8e92ec920fe5e6741"}}, :trace nil}} | |
| \ No newline at end of file | ||
| new file mode 100644 | |||
| @@ -0,0 +1 @@ | |||
| 1 | +{:material "1598:{:paths [\"src\"]\n\n ;; glimmer-vidya is a backend for glimmer: glimmer owns the reactive core\n ;; (ratom, component model, reconciler) and knows nothing about any toolkit;\n ;; this project supplies the widgets, painting and the frame loop by way of\n ;; Vidya's retained-tree C ABI, and registers them through glimmer.backend.\n :deps {jolt-lang/glimmer {:git/url \"https://github.com/jolt-lang/glimmer\"\n :git/tag \"v0.1.0\"\n :git/sha \"5581c331c51aff989259b9e8e92ec920fe5e6741\"}}\n\n ;; libvidya, built from ../ffi (the Rust/egui implementation). Only that\n ;; backend implements the tree ABI this project binds — the C/raylib build of\n ;; libvidya exports vidya.h alone, so it is deliberately NOT interchangeable\n ;; here, unlike for ../jolt. Put the built library on the search path:\n ;; just ffi\n ;; LD_LIBRARY_PATH=../build jolt counter\n :jolt/native [{:name \"vidya\"\n :darwin [\"libvidya.dylib\"]\n :linux [\"libvidya.so\"]}]\n\n :aliases {:test {:extra-paths [\"test\"]\n :main-opts [\"-m\" \"glimmer-vidya.test-runner\"]}\n :counter {:extra-paths [\"examples\"]\n :main-opts [\"-m\" \"glimmer-vidya.counter\"]}\n :showcase {:extra-paths [\"examples\"]\n :main-opts [\"-m\" \"glimmer-vidya.showcase\"]}\n :smoke {:extra-paths [\"examples\"]\n :main-opts [\"-m\" \"glimmer-vidya.smoke\"]}}\n\n :tasks {test \"jolt -M:test\"\n counter \"jolt -M:counter\"\n showcase \"jolt -M:showcase\"\n smoke \"jolt -M:smoke\"}}\n|10:::absent::|[:test]|[[jolt-lang/glimmer #:git{:url \"https://github.com/jolt-lang/glimmer\", :tag \"v0.1.0\", :sha \"5581c331c51aff989259b9e8e92ec920fe5e6741\"}]]|[]|[]|v0.7.28|/var/home/nandi/.jolt/gitlibs|||||/var/home/nandi|", :value {:roots ["/var/home/nandi/.jolt/gitlibs/https___github.com_jolt-lang_glimmer/5581c331c51aff989259b9e8e92ec920fe5e6741/src"], :natives [], :prep [], :libs {jolt-lang/glimmer #:git{:url "https://github.com/jolt-lang/glimmer", :tag "v0.1.0", :sha "5581c331c51aff989259b9e8e92ec920fe5e6741"}}, :trace nil}} | ||
| \ No newline at end of file | \ No newline at end of file | ||
added
jolt/glimmer-vidya/README.md +250 -0 | new file mode 100644 | ||
| @@ -0,0 +1,250 @@ | ||
| 1 | +# glimmer-vidya | |
| 2 | + | |
| 3 | +The **Vidya/egui** backend for [glimmer](https://github.com/jolt-lang/glimmer), | |
| 4 | +the reactive GUI toolkit for [jolt](https://github.com/jolt-lang/jolt). | |
| 5 | + | |
| 6 | +glimmer owns the portable half — reactive cells, the component model, the | |
| 7 | +reconciler — and knows nothing about any toolkit. This project supplies the | |
| 8 | +other half for a GPU window: Vidya's widgets and theme, painted by egui through | |
| 9 | +[`../ffi`](../ffi/README.md). Requiring `glimmer-vidya.core` registers it, and | |
| 10 | +components that render as GTK widgets under | |
| 11 | +[glimmer-gtk](https://github.com/jolt-lang/glimmer-gtk), and as text under | |
| 12 | +[glimmer-tui](https://github.com/jolt-lang/glimmer-tui), render here as Vidya. | |
| 13 | + | |
| 14 | +```clojure | |
| 15 | +(ns myapp | |
| 16 | + (:require [glimmer.ratom :as r :refer [atom]] | |
| 17 | + [glimmer.core :as ui] | |
| 18 | + [glimmer-vidya.core])) ; installs this backend | |
| 19 | + | |
| 20 | +(defn counter [] | |
| 21 | + (let [count (atom 0)] | |
| 22 | + (fn [] | |
| 23 | + [:page {:max-width 420} | |
| 24 | + [:card {} | |
| 25 | + [:title {:label "Counter"}] | |
| 26 | + [:label {:label (str "Count: " @count)}] | |
| 27 | + [:hbox {:spacing 8} | |
| 28 | + [:button {:label "- 1" :on-click #(swap! count dec)}] | |
| 29 | + [:button {:label "+ 1" :kind :primary :on-click #(swap! count inc)}] | |
| 30 | + [:button {:label "reset" :on-click #(reset! count 0)}]]]]))) | |
| 31 | + | |
| 32 | +(defn -main [& _] (ui/run counter :title "counter" :width 480 :height 320)) | |
| 33 | +``` | |
| 34 | + | |
| 35 | +Components, reactive state and reconciliation are documented in glimmer's | |
| 36 | +README. What follows is the Vidya-specific part. | |
| 37 | + | |
| 38 | +## How an immediate-mode toolkit holds still | |
| 39 | + | |
| 40 | +egui has no widgets. It has calls you make every frame, and a reconciler has | |
| 41 | +nothing to reconcile against — no pointer to patch, nothing to append a child | |
| 42 | +to. GTK hands glimmer a `GtkButton`; egui hands it nothing at all. | |
| 43 | + | |
| 44 | +So the widget tree lives one layer down, in Rust. `libvidya` keeps a node arena | |
| 45 | +behind a second C ABI ([`../ffi/include/vidya_tree.h`](../ffi/include/vidya_tree.h)): | |
| 46 | +nodes are integer handles, and this backend's `create!` / `apply-props!` / | |
| 47 | +`append-child!` mutate them. Nothing is painted by those calls. Once a frame, | |
| 48 | +`vidya_tree_frame` walks the whole tree and emits the egui calls it describes. | |
| 49 | + | |
| 50 | +Two things follow from putting the tree there rather than here: | |
| 51 | + | |
| 52 | +* **FFI traffic tracks edits, not frames.** A static UI costs no crossings per | |
| 53 | + frame; only what the reconciler actually changed is sent. The alternative — | |
| 54 | + keeping the tree in jolt and walking it over the FFI 60 times a second — | |
| 55 | + would put the frame rate at the mercy of the reconciler's thread. | |
| 56 | +* **The closure-shaped parts of egui work.** `ScrollArea` and `Frame` take an | |
| 57 | + `FnOnce(&mut Ui)` and keep their `begin`/`end` private, which is why Vidya's | |
| 58 | + original push/pop ABI could not scroll a page. Painting from a tree already | |
| 59 | + in hand means the recursion *is* the closure. | |
| 60 | + | |
| 61 | +**Handlers do not cross the boundary.** A jolt closure cannot be a callback in | |
| 62 | +a library painting at 60fps, so identity travels instead: a node reports that it | |
| 63 | +was clicked, `glimmer-vidya.core` looks up whose `:on-click` that was, and calls | |
| 64 | +it on the loop thread. Handlers are held on the jolt side and never sent. | |
| 65 | + | |
| 66 | +## Requirements | |
| 67 | + | |
| 68 | +`libvidya` built from [`../ffi`](../ffi), the Rust/egui implementation: | |
| 69 | + | |
| 70 | +```sh | |
| 71 | +just ffi # buck2 → ../build/libvidya.so | |
| 72 | +``` | |
| 73 | + | |
| 74 | +Then put it on the search path when running anything here: | |
| 75 | + | |
| 76 | +```sh | |
| 77 | +LD_LIBRARY_PATH=../build jolt counter | |
| 78 | +``` | |
| 79 | + | |
| 80 | +`just ffi-android` cross-compiles the same library to | |
| 81 | +`../build/android/arm64-v8a/libvidya.so` for a 64-bit device — both ABIs this | |
| 82 | +backend binds are exported there too. | |
| 83 | + | |
| 84 | +On macOS use `DYLD_LIBRARY_PATH`. Note that this is the one place in the repo | |
| 85 | +where the two `libvidya` builds are **not** interchangeable: `../raylib` | |
| 86 | +implements `vidya.h` only, and this backend binds the tree ABI, which is the | |
| 87 | +Rust build's alone. | |
| 88 | + | |
| 89 | +## Running | |
| 90 | + | |
| 91 | +```sh | |
| 92 | +jolt test # the suite, headless: no window, display or GPU needed | |
| 93 | +jolt counter # the counter above | |
| 94 | +jolt showcase # every tag, a keyed task list, an entry, a disabled subtree | |
| 95 | +jolt smoke # non-interactive: reconciles under paint, then quits | |
| 96 | +``` | |
| 97 | + | |
| 98 | +## Seeing what was rendered | |
| 99 | + | |
| 100 | +`dump` reads the tree back out of the library as hiccup — what is actually | |
| 101 | +mounted, after the reconciler has had its way with it, rather than what a | |
| 102 | +component returned: | |
| 103 | + | |
| 104 | +```clojure | |
| 105 | +(require '[glimmer-vidya.core :as backend]) | |
| 106 | + | |
| 107 | +(backend/dump!) ; print the whole window | |
| 108 | +(backend/dump! node) ; or one subtree | |
| 109 | +(backend/dump) ; the same as hiccup data, for a test | |
| 110 | +(backend/dump-str) ; as text, to paste into a bug report | |
| 111 | +``` | |
| 112 | + | |
| 113 | +```clojure | |
| 114 | +[:window {} | |
| 115 | + [:card {} | |
| 116 | + [:title {:label "Counter"}] | |
| 117 | + [:label {:label "Count: 3"}] | |
| 118 | + [:box {:orientation "horizontal" :spacing 8} | |
| 119 | + [:button {:label "- 1"}] | |
| 120 | + [:button {:kind "primary" :label "+ 1"}]]]] | |
| 121 | +``` | |
| 122 | + | |
| 123 | +Two things to expect when reading one, both of them the boundary showing | |
| 124 | +through. `:hbox` and `:vbox` are one node down there, so both dump as `:box` | |
| 125 | +with the orientation in the props; and no `:on-*` appears, because handlers are | |
| 126 | +held on this side and never sent. Props are sorted, so two dumps of the same | |
| 127 | +tree compare as text. | |
| 128 | + | |
| 129 | +It needs no window — the tree is only painted by `vidya_tree_frame` — so a | |
| 130 | +headless test can mount a component and assert on `(dump root)` directly. | |
| 131 | + | |
| 132 | +## Hiccup reference | |
| 133 | + | |
| 134 | +Elements are `[:tag props? & children]`, as everywhere in glimmer. Strings and | |
| 135 | +numbers become labels, `nil` children are skipped, seqs are spliced. | |
| 136 | + | |
| 137 | +**Containers** | |
| 138 | + | |
| 139 | +| tag | holds | notes | | |
| 140 | +|---|---|---| | |
| 141 | +| `:window` | many | the root; you rarely name it | | |
| 142 | +| `:box` / `:hbox` / `:vbox` | many | `:orientation :horizontal\|:vertical`, implied by the tag | | |
| 143 | +| `:page` | many | scrolling column with page padding; `:max-width` centres it | | |
| 144 | +| `:scroll` | many | `:orientation :vertical\|:horizontal\|:both`, `:scroll-key` | | |
| 145 | +| `:card` | many | Vidya's raised surface | | |
| 146 | +| `:frame` | many | a card with a `:label` as its heading | | |
| 147 | + | |
| 148 | +**Widgets** | |
| 149 | + | |
| 150 | +| tag | shows | | |
| 151 | +|---|---| | |
| 152 | +| `:label` | body text | | |
| 153 | +| `:title` / `:title-2` / `:dim-label` | the type scale's other roles | | |
| 154 | +| `:button` | `:kind :default\|:primary\|:destructive` | | |
| 155 | +| `:checkbutton` | Vidya's themed checkbox (`:checkbox` is an alias) | | |
| 156 | +| `:entry` | a text field; `:multiline true` with `:rows` for a box | | |
| 157 | +| `:separator` | a rule | | |
| 158 | +| `:spacer` | blank space of `:size` points | | |
| 159 | +| `:progress` | a bar, `:value` 0.0–1.0, with an optional `:label` | | |
| 160 | +| `:spinner` | an indeterminate spinner | | |
| 161 | +| `:status` | a live/offline dot beside a label | | |
| 162 | +| `:image` | a picture from a file; `:max-height` bounds it, `:fit true` fills and centres it in the space it is given (the one case that scales up) | | |
| 163 | + | |
| 164 | +**Common props** | |
| 165 | + | |
| 166 | +- `:sensitive false` — dims the widget *and its whole subtree*, and takes it out | |
| 167 | + of egui's interaction | |
| 168 | +- `:width-request` — a fixed width. Worth more here than it sounds: immediate | |
| 169 | + mode has no natural width for a field, so an `:entry` asks for whatever is | |
| 170 | + left and takes the row it shares with a button. This is how you say otherwise. | |
| 171 | +- `:margin`, `:spacing` — on containers | |
| 172 | + | |
| 173 | +**Events** | |
| 174 | + | |
| 175 | +- `:on-click` — button pressed. No args. | |
| 176 | +- `:on-toggled` — checkbutton clicked. No args. | |
| 177 | +- `:on-change` — entry text changed. Receives the new text. | |
| 178 | +- `:on-activate` — Enter pressed in an entry. No args. | |
| 179 | +- `:on-paste-empty` — Ctrl+V in an entry with no text on the clipboard, which | |
| 180 | + is what a copied picture looks like from there. No args; ask | |
| 181 | + `clipboard-image-png!` what is actually on it. | |
| 182 | + | |
| 183 | +As in the other backends, a handler owns the state: `:on-toggled` flips the cell | |
| 184 | +the component reads, and `:active` comes back down as a prop. A control that | |
| 185 | +ignores its own event still works — the library wrote the new state into the | |
| 186 | +node, and the next render either confirms it or overwrites it. | |
| 187 | + | |
| 188 | +An unrecognized tag is kept rather than refused: it paints as a vertical box, so | |
| 189 | +a component written against a tag this backend has not grown yet still shows its | |
| 190 | +children. | |
| 191 | + | |
| 192 | +## Options for `ui/run` | |
| 193 | + | |
| 194 | +On top of glimmer's own `:title`, `:width`, `:height` and `:auto-quit-ms`: | |
| 195 | + | |
| 196 | +| option | | | |
| 197 | +|---|---| | |
| 198 | +| `:fps` | frame rate cap, default 60 | | |
| 199 | +| `:mode` | `:dark` (the default) or `:light` | | |
| 200 | +| `:font` | path to a TTF/OTF for UI text | | |
| 201 | + | |
| 202 | +## Timers | |
| 203 | + | |
| 204 | +The loop wakes every frame anyway, so a timer is a due time and a thunk. Both | |
| 205 | +run on the loop thread, the only one allowed to touch nodes: | |
| 206 | + | |
| 207 | +```clojure | |
| 208 | +(vidya/every! 80 #(swap! tick inc)) ; a spinner, a clock, a progress bar | |
| 209 | +(vidya/after! 500 #(reset! ready true)) | |
| 210 | +(vidya/cancel! id) | |
| 211 | +``` | |
| 212 | + | |
| 213 | +`(vidya/quit!)` stops the loop and closes the window. | |
| 214 | + | |
| 215 | +## Threads | |
| 216 | + | |
| 217 | +Every node call belongs to the thread that opened the window — the library | |
| 218 | +enforces it, keeping its state in thread-local storage. glimmer already knows | |
| 219 | +this: while the loop runs it marshals each component's re-render through the | |
| 220 | +backend's `schedule`, which queues the work for the next tick. So a `swap!` from | |
| 221 | +an nREPL worker is safe, and `glimmer.core/on-gui` is there for code that wants | |
| 222 | +to touch the tree directly. | |
| 223 | + | |
| 224 | +## Testing | |
| 225 | + | |
| 226 | +The suite is headless. Node calls need no GL surface — only `vidya_tree_frame` | |
| 227 | +does — so `jolt test` mounts real components, reconciles them, and reads the | |
| 228 | +resulting tree back through the same ABI the backend writes it with. It asserts | |
| 229 | +what was created, what was patched in place, what was replaced, and that a keyed | |
| 230 | +list reorders its widgets rather than rebuilding them. No window is opened and | |
| 231 | +no display is required, which is also what makes it run in CI. | |
| 232 | + | |
| 233 | +Painting is checked separately, by `jolt smoke` under `VIDYA_CAPTURE`: | |
| 234 | + | |
| 235 | +```sh | |
| 236 | +VIDYA_CAPTURE=/tmp/frame.ppm LD_LIBRARY_PATH=../build jolt smoke | |
| 237 | +``` | |
| 238 | + | |
| 239 | +## Limits | |
| 240 | + | |
| 241 | +* **X11 is preferred on Linux**, for the reason in | |
| 242 | + [`../ffi/README.md`](../ffi/README.md): the caller owns the frame loop, and a | |
| 243 | + native Wayland surface driven that way stops receiving frame callbacks. Under | |
| 244 | + XWayland everything works; fractional scaling follows XWayland's rules. | |
| 245 | +* **No focus or keyboard navigation of your own.** egui owns focus, tabbing and | |
| 246 | + hit testing, so there is nothing here like glimmer-tui's `:keys`, `:on-key` or | |
| 247 | + `:autofocus` — and nothing to configure. | |
| 248 | +* **No `:listbox`, `:table`, `:overlay` or `:paginator` yet.** A list is a | |
| 249 | + keyed `:vbox` for now. These are widget-layer work in `../ffi/src/tree.rs` | |
| 250 | + plus a tag; nothing in the design is in the way. | |
| new file mode 100644 | |||
| @@ -0,0 +1,250 @@ | |||
| 1 | +# glimmer-vidya | ||
| 2 | + | ||
| 3 | +The **Vidya/egui** backend for [glimmer](https://github.com/jolt-lang/glimmer), | ||
| 4 | +the reactive GUI toolkit for [jolt](https://github.com/jolt-lang/jolt). | ||
| 5 | + | ||
| 6 | +glimmer owns the portable half — reactive cells, the component model, the | ||
| 7 | +reconciler — and knows nothing about any toolkit. This project supplies the | ||
| 8 | +other half for a GPU window: Vidya's widgets and theme, painted by egui through | ||
| 9 | +[`../ffi`](../ffi/README.md). Requiring `glimmer-vidya.core` registers it, and | ||
| 10 | +components that render as GTK widgets under | ||
| 11 | +[glimmer-gtk](https://github.com/jolt-lang/glimmer-gtk), and as text under | ||
| 12 | +[glimmer-tui](https://github.com/jolt-lang/glimmer-tui), render here as Vidya. | ||
| 13 | + | ||
| 14 | +```clojure | ||
| 15 | +(ns myapp | ||
| 16 | + (:require [glimmer.ratom :as r :refer [atom]] | ||
| 17 | + [glimmer.core :as ui] | ||
| 18 | + [glimmer-vidya.core])) ; installs this backend | ||
| 19 | + | ||
| 20 | +(defn counter [] | ||
| 21 | + (let [count (atom 0)] | ||
| 22 | + (fn [] | ||
| 23 | + [:page {:max-width 420} | ||
| 24 | + [:card {} | ||
| 25 | + [:title {:label "Counter"}] | ||
| 26 | + [:label {:label (str "Count: " @count)}] | ||
| 27 | + [:hbox {:spacing 8} | ||
| 28 | + [:button {:label "- 1" :on-click #(swap! count dec)}] | ||
| 29 | + [:button {:label "+ 1" :kind :primary :on-click #(swap! count inc)}] | ||
| 30 | + [:button {:label "reset" :on-click #(reset! count 0)}]]]]))) | ||
| 31 | + | ||
| 32 | +(defn -main [& _] (ui/run counter :title "counter" :width 480 :height 320)) | ||
| 33 | +``` | ||
| 34 | + | ||
| 35 | +Components, reactive state and reconciliation are documented in glimmer's | ||
| 36 | +README. What follows is the Vidya-specific part. | ||
| 37 | + | ||
| 38 | +## How an immediate-mode toolkit holds still | ||
| 39 | + | ||
| 40 | +egui has no widgets. It has calls you make every frame, and a reconciler has | ||
| 41 | +nothing to reconcile against — no pointer to patch, nothing to append a child | ||
| 42 | +to. GTK hands glimmer a `GtkButton`; egui hands it nothing at all. | ||
| 43 | + | ||
| 44 | +So the widget tree lives one layer down, in Rust. `libvidya` keeps a node arena | ||
| 45 | +behind a second C ABI ([`../ffi/include/vidya_tree.h`](../ffi/include/vidya_tree.h)): | ||
| 46 | +nodes are integer handles, and this backend's `create!` / `apply-props!` / | ||
| 47 | +`append-child!` mutate them. Nothing is painted by those calls. Once a frame, | ||
| 48 | +`vidya_tree_frame` walks the whole tree and emits the egui calls it describes. | ||
| 49 | + | ||
| 50 | +Two things follow from putting the tree there rather than here: | ||
| 51 | + | ||
| 52 | +* **FFI traffic tracks edits, not frames.** A static UI costs no crossings per | ||
| 53 | + frame; only what the reconciler actually changed is sent. The alternative — | ||
| 54 | + keeping the tree in jolt and walking it over the FFI 60 times a second — | ||
| 55 | + would put the frame rate at the mercy of the reconciler's thread. | ||
| 56 | +* **The closure-shaped parts of egui work.** `ScrollArea` and `Frame` take an | ||
| 57 | + `FnOnce(&mut Ui)` and keep their `begin`/`end` private, which is why Vidya's | ||
| 58 | + original push/pop ABI could not scroll a page. Painting from a tree already | ||
| 59 | + in hand means the recursion *is* the closure. | ||
| 60 | + | ||
| 61 | +**Handlers do not cross the boundary.** A jolt closure cannot be a callback in | ||
| 62 | +a library painting at 60fps, so identity travels instead: a node reports that it | ||
| 63 | +was clicked, `glimmer-vidya.core` looks up whose `:on-click` that was, and calls | ||
| 64 | +it on the loop thread. Handlers are held on the jolt side and never sent. | ||
| 65 | + | ||
| 66 | +## Requirements | ||
| 67 | + | ||
| 68 | +`libvidya` built from [`../ffi`](../ffi), the Rust/egui implementation: | ||
| 69 | + | ||
| 70 | +```sh | ||
| 71 | +just ffi # buck2 → ../build/libvidya.so | ||
| 72 | +``` | ||
| 73 | + | ||
| 74 | +Then put it on the search path when running anything here: | ||
| 75 | + | ||
| 76 | +```sh | ||
| 77 | +LD_LIBRARY_PATH=../build jolt counter | ||
| 78 | +``` | ||
| 79 | + | ||
| 80 | +`just ffi-android` cross-compiles the same library to | ||
| 81 | +`../build/android/arm64-v8a/libvidya.so` for a 64-bit device — both ABIs this | ||
| 82 | +backend binds are exported there too. | ||
| 83 | + | ||
| 84 | +On macOS use `DYLD_LIBRARY_PATH`. Note that this is the one place in the repo | ||
| 85 | +where the two `libvidya` builds are **not** interchangeable: `../raylib` | ||
| 86 | +implements `vidya.h` only, and this backend binds the tree ABI, which is the | ||
| 87 | +Rust build's alone. | ||
| 88 | + | ||
| 89 | +## Running | ||
| 90 | + | ||
| 91 | +```sh | ||
| 92 | +jolt test # the suite, headless: no window, display or GPU needed | ||
| 93 | +jolt counter # the counter above | ||
| 94 | +jolt showcase # every tag, a keyed task list, an entry, a disabled subtree | ||
| 95 | +jolt smoke # non-interactive: reconciles under paint, then quits | ||
| 96 | +``` | ||
| 97 | + | ||
| 98 | +## Seeing what was rendered | ||
| 99 | + | ||
| 100 | +`dump` reads the tree back out of the library as hiccup — what is actually | ||
| 101 | +mounted, after the reconciler has had its way with it, rather than what a | ||
| 102 | +component returned: | ||
| 103 | + | ||
| 104 | +```clojure | ||
| 105 | +(require '[glimmer-vidya.core :as backend]) | ||
| 106 | + | ||
| 107 | +(backend/dump!) ; print the whole window | ||
| 108 | +(backend/dump! node) ; or one subtree | ||
| 109 | +(backend/dump) ; the same as hiccup data, for a test | ||
| 110 | +(backend/dump-str) ; as text, to paste into a bug report | ||
| 111 | +``` | ||
| 112 | + | ||
| 113 | +```clojure | ||
| 114 | +[:window {} | ||
| 115 | + [:card {} | ||
| 116 | + [:title {:label "Counter"}] | ||
| 117 | + [:label {:label "Count: 3"}] | ||
| 118 | + [:box {:orientation "horizontal" :spacing 8} | ||
| 119 | + [:button {:label "- 1"}] | ||
| 120 | + [:button {:kind "primary" :label "+ 1"}]]]] | ||
| 121 | +``` | ||
| 122 | + | ||
| 123 | +Two things to expect when reading one, both of them the boundary showing | ||
| 124 | +through. `:hbox` and `:vbox` are one node down there, so both dump as `:box` | ||
| 125 | +with the orientation in the props; and no `:on-*` appears, because handlers are | ||
| 126 | +held on this side and never sent. Props are sorted, so two dumps of the same | ||
| 127 | +tree compare as text. | ||
| 128 | + | ||
| 129 | +It needs no window — the tree is only painted by `vidya_tree_frame` — so a | ||
| 130 | +headless test can mount a component and assert on `(dump root)` directly. | ||
| 131 | + | ||
| 132 | +## Hiccup reference | ||
| 133 | + | ||
| 134 | +Elements are `[:tag props? & children]`, as everywhere in glimmer. Strings and | ||
| 135 | +numbers become labels, `nil` children are skipped, seqs are spliced. | ||
| 136 | + | ||
| 137 | +**Containers** | ||
| 138 | + | ||
| 139 | +| tag | holds | notes | | ||
| 140 | +|---|---|---| | ||
| 141 | +| `:window` | many | the root; you rarely name it | | ||
| 142 | +| `:box` / `:hbox` / `:vbox` | many | `:orientation :horizontal\|:vertical`, implied by the tag | | ||
| 143 | +| `:page` | many | scrolling column with page padding; `:max-width` centres it | | ||
| 144 | +| `:scroll` | many | `:orientation :vertical\|:horizontal\|:both`, `:scroll-key` | | ||
| 145 | +| `:card` | many | Vidya's raised surface | | ||
| 146 | +| `:frame` | many | a card with a `:label` as its heading | | ||
| 147 | + | ||
| 148 | +**Widgets** | ||
| 149 | + | ||
| 150 | +| tag | shows | | ||
| 151 | +|---|---| | ||
| 152 | +| `:label` | body text | | ||
| 153 | +| `:title` / `:title-2` / `:dim-label` | the type scale's other roles | | ||
| 154 | +| `:button` | `:kind :default\|:primary\|:destructive` | | ||
| 155 | +| `:checkbutton` | Vidya's themed checkbox (`:checkbox` is an alias) | | ||
| 156 | +| `:entry` | a text field; `:multiline true` with `:rows` for a box | | ||
| 157 | +| `:separator` | a rule | | ||
| 158 | +| `:spacer` | blank space of `:size` points | | ||
| 159 | +| `:progress` | a bar, `:value` 0.0–1.0, with an optional `:label` | | ||
| 160 | +| `:spinner` | an indeterminate spinner | | ||
| 161 | +| `:status` | a live/offline dot beside a label | | ||
| 162 | +| `:image` | a picture from a file; `:max-height` bounds it, `:fit true` fills and centres it in the space it is given (the one case that scales up) | | ||
| 163 | + | ||
| 164 | +**Common props** | ||
| 165 | + | ||
| 166 | +- `:sensitive false` — dims the widget *and its whole subtree*, and takes it out | ||
| 167 | + of egui's interaction | ||
| 168 | +- `:width-request` — a fixed width. Worth more here than it sounds: immediate | ||
| 169 | + mode has no natural width for a field, so an `:entry` asks for whatever is | ||
| 170 | + left and takes the row it shares with a button. This is how you say otherwise. | ||
| 171 | +- `:margin`, `:spacing` — on containers | ||
| 172 | + | ||
| 173 | +**Events** | ||
| 174 | + | ||
| 175 | +- `:on-click` — button pressed. No args. | ||
| 176 | +- `:on-toggled` — checkbutton clicked. No args. | ||
| 177 | +- `:on-change` — entry text changed. Receives the new text. | ||
| 178 | +- `:on-activate` — Enter pressed in an entry. No args. | ||
| 179 | +- `:on-paste-empty` — Ctrl+V in an entry with no text on the clipboard, which | ||
| 180 | + is what a copied picture looks like from there. No args; ask | ||
| 181 | + `clipboard-image-png!` what is actually on it. | ||
| 182 | + | ||
| 183 | +As in the other backends, a handler owns the state: `:on-toggled` flips the cell | ||
| 184 | +the component reads, and `:active` comes back down as a prop. A control that | ||
| 185 | +ignores its own event still works — the library wrote the new state into the | ||
| 186 | +node, and the next render either confirms it or overwrites it. | ||
| 187 | + | ||
| 188 | +An unrecognized tag is kept rather than refused: it paints as a vertical box, so | ||
| 189 | +a component written against a tag this backend has not grown yet still shows its | ||
| 190 | +children. | ||
| 191 | + | ||
| 192 | +## Options for `ui/run` | ||
| 193 | + | ||
| 194 | +On top of glimmer's own `:title`, `:width`, `:height` and `:auto-quit-ms`: | ||
| 195 | + | ||
| 196 | +| option | | | ||
| 197 | +|---|---| | ||
| 198 | +| `:fps` | frame rate cap, default 60 | | ||
| 199 | +| `:mode` | `:dark` (the default) or `:light` | | ||
| 200 | +| `:font` | path to a TTF/OTF for UI text | | ||
| 201 | + | ||
| 202 | +## Timers | ||
| 203 | + | ||
| 204 | +The loop wakes every frame anyway, so a timer is a due time and a thunk. Both | ||
| 205 | +run on the loop thread, the only one allowed to touch nodes: | ||
| 206 | + | ||
| 207 | +```clojure | ||
| 208 | +(vidya/every! 80 #(swap! tick inc)) ; a spinner, a clock, a progress bar | ||
| 209 | +(vidya/after! 500 #(reset! ready true)) | ||
| 210 | +(vidya/cancel! id) | ||
| 211 | +``` | ||
| 212 | + | ||
| 213 | +`(vidya/quit!)` stops the loop and closes the window. | ||
| 214 | + | ||
| 215 | +## Threads | ||
| 216 | + | ||
| 217 | +Every node call belongs to the thread that opened the window — the library | ||
| 218 | +enforces it, keeping its state in thread-local storage. glimmer already knows | ||
| 219 | +this: while the loop runs it marshals each component's re-render through the | ||
| 220 | +backend's `schedule`, which queues the work for the next tick. So a `swap!` from | ||
| 221 | +an nREPL worker is safe, and `glimmer.core/on-gui` is there for code that wants | ||
| 222 | +to touch the tree directly. | ||
| 223 | + | ||
| 224 | +## Testing | ||
| 225 | + | ||
| 226 | +The suite is headless. Node calls need no GL surface — only `vidya_tree_frame` | ||
| 227 | +does — so `jolt test` mounts real components, reconciles them, and reads the | ||
| 228 | +resulting tree back through the same ABI the backend writes it with. It asserts | ||
| 229 | +what was created, what was patched in place, what was replaced, and that a keyed | ||
| 230 | +list reorders its widgets rather than rebuilding them. No window is opened and | ||
| 231 | +no display is required, which is also what makes it run in CI. | ||
| 232 | + | ||
| 233 | +Painting is checked separately, by `jolt smoke` under `VIDYA_CAPTURE`: | ||
| 234 | + | ||
| 235 | +```sh | ||
| 236 | +VIDYA_CAPTURE=/tmp/frame.ppm LD_LIBRARY_PATH=../build jolt smoke | ||
| 237 | +``` | ||
| 238 | + | ||
| 239 | +## Limits | ||
| 240 | + | ||
| 241 | +* **X11 is preferred on Linux**, for the reason in | ||
| 242 | + [`../ffi/README.md`](../ffi/README.md): the caller owns the frame loop, and a | ||
| 243 | + native Wayland surface driven that way stops receiving frame callbacks. Under | ||
| 244 | + XWayland everything works; fractional scaling follows XWayland's rules. | ||
| 245 | +* **No focus or keyboard navigation of your own.** egui owns focus, tabbing and | ||
| 246 | + hit testing, so there is nothing here like glimmer-tui's `:keys`, `:on-key` or | ||
| 247 | + `:autofocus` — and nothing to configure. | ||
| 248 | +* **No `:listbox`, `:table`, `:overlay` or `:paginator` yet.** A list is a | ||
| 249 | + keyed `:vbox` for now. These are widget-layer work in `../ffi/src/tree.rs` | ||
| 250 | + plus a tag; nothing in the design is in the way. | ||
added
jolt/glimmer-vidya/deps.edn +33 -0 | new file mode 100644 | ||
| @@ -0,0 +1,33 @@ | ||
| 1 | +{:paths ["src"] | |
| 2 | + | |
| 3 | + ;; glimmer-vidya is a backend for glimmer: glimmer owns the reactive core | |
| 4 | + ;; (ratom, component model, reconciler) and knows nothing about any toolkit; | |
| 5 | + ;; this project supplies the widgets, painting and the frame loop by way of | |
| 6 | + ;; Vidya's retained-tree C ABI, and registers them through glimmer.backend. | |
| 7 | + :deps {jolt-lang/glimmer {:git/url "https://github.com/jolt-lang/glimmer" | |
| 8 | + :git/tag "v0.1.0" | |
| 9 | + :git/sha "5581c331c51aff989259b9e8e92ec920fe5e6741"}} | |
| 10 | + | |
| 11 | + ;; libvidya, built from ../ffi (the Rust/egui implementation). Only that | |
| 12 | + ;; backend implements the tree ABI this project binds — the C/raylib build of | |
| 13 | + ;; libvidya exports vidya.h alone, so it is deliberately NOT interchangeable | |
| 14 | + ;; here, unlike for ../jolt. Put the built library on the search path: | |
| 15 | + ;; just ffi | |
| 16 | + ;; LD_LIBRARY_PATH=../build jolt counter | |
| 17 | + :jolt/native [{:name "vidya" | |
| 18 | + :darwin ["libvidya.dylib"] | |
| 19 | + :linux ["libvidya.so"]}] | |
| 20 | + | |
| 21 | + :aliases {:test {:extra-paths ["test"] | |
| 22 | + :main-opts ["-m" "glimmer-vidya.test-runner"]} | |
| 23 | + :counter {:extra-paths ["examples"] | |
| 24 | + :main-opts ["-m" "glimmer-vidya.counter"]} | |
| 25 | + :showcase {:extra-paths ["examples"] | |
| 26 | + :main-opts ["-m" "glimmer-vidya.showcase"]} | |
| 27 | + :smoke {:extra-paths ["examples"] | |
| 28 | + :main-opts ["-m" "glimmer-vidya.smoke"]}} | |
| 29 | + | |
| 30 | + :tasks {test "jolt -M:test" | |
| 31 | + counter "jolt -M:counter" | |
| 32 | + showcase "jolt -M:showcase" | |
| 33 | + smoke "jolt -M:smoke"}} | |
| new file mode 100644 | |||
| @@ -0,0 +1,33 @@ | |||
| 1 | +{:paths ["src"] | ||
| 2 | + | ||
| 3 | + ;; glimmer-vidya is a backend for glimmer: glimmer owns the reactive core | ||
| 4 | + ;; (ratom, component model, reconciler) and knows nothing about any toolkit; | ||
| 5 | + ;; this project supplies the widgets, painting and the frame loop by way of | ||
| 6 | + ;; Vidya's retained-tree C ABI, and registers them through glimmer.backend. | ||
| 7 | + :deps {jolt-lang/glimmer {:git/url "https://github.com/jolt-lang/glimmer" | ||
| 8 | + :git/tag "v0.1.0" | ||
| 9 | + :git/sha "5581c331c51aff989259b9e8e92ec920fe5e6741"}} | ||
| 10 | + | ||
| 11 | + ;; libvidya, built from ../ffi (the Rust/egui implementation). Only that | ||
| 12 | + ;; backend implements the tree ABI this project binds — the C/raylib build of | ||
| 13 | + ;; libvidya exports vidya.h alone, so it is deliberately NOT interchangeable | ||
| 14 | + ;; here, unlike for ../jolt. Put the built library on the search path: | ||
| 15 | + ;; just ffi | ||
| 16 | + ;; LD_LIBRARY_PATH=../build jolt counter | ||
| 17 | + :jolt/native [{:name "vidya" | ||
| 18 | + :darwin ["libvidya.dylib"] | ||
| 19 | + :linux ["libvidya.so"]}] | ||
| 20 | + | ||
| 21 | + :aliases {:test {:extra-paths ["test"] | ||
| 22 | + :main-opts ["-m" "glimmer-vidya.test-runner"]} | ||
| 23 | + :counter {:extra-paths ["examples"] | ||
| 24 | + :main-opts ["-m" "glimmer-vidya.counter"]} | ||
| 25 | + :showcase {:extra-paths ["examples"] | ||
| 26 | + :main-opts ["-m" "glimmer-vidya.showcase"]} | ||
| 27 | + :smoke {:extra-paths ["examples"] | ||
| 28 | + :main-opts ["-m" "glimmer-vidya.smoke"]}} | ||
| 29 | + | ||
| 30 | + :tasks {test "jolt -M:test" | ||
| 31 | + counter "jolt -M:counter" | ||
| 32 | + showcase "jolt -M:showcase" | ||
| 33 | + smoke "jolt -M:smoke"}} | ||
added
jolt/glimmer-vidya/examples/glimmer_vidya/counter.jolt +23 -0 | new file mode 100644 | ||
| @@ -0,0 +1,23 @@ | ||
| 1 | +(ns glimmer-vidya.counter | |
| 2 | + "glimmer's counter, rendered by egui. | |
| 3 | + | |
| 4 | + The only line that names a toolkit is the backend require — the component | |
| 5 | + below is the one from glimmer's own README." | |
| 6 | + (:require [glimmer.ratom :as r :refer [atom]] | |
| 7 | + [glimmer.core :as ui] | |
| 8 | + [glimmer-vidya.core])) | |
| 9 | + | |
| 10 | +(defn counter [] | |
| 11 | + (let [count (atom 0)] | |
| 12 | + (fn [] | |
| 13 | + [:page {:max-width 420} | |
| 14 | + [:card {} | |
| 15 | + [:title {:label "Counter"}] | |
| 16 | + [:label {:label (str "Count: " @count)}] | |
| 17 | + [:hbox {:spacing 8} | |
| 18 | + [:button {:label "- 1" :on-click #(swap! count dec)}] | |
| 19 | + [:button {:label "+ 1" :kind :primary :on-click #(swap! count inc)}] | |
| 20 | + [:button {:label "reset" :on-click #(reset! count 0)}]]]]))) | |
| 21 | + | |
| 22 | +(defn -main [& _] | |
| 23 | + (ui/run counter :title "counter" :width 480 :height 320)) | |
| new file mode 100644 | |||
| @@ -0,0 +1,23 @@ | |||
| 1 | +(ns glimmer-vidya.counter | ||
| 2 | + "glimmer's counter, rendered by egui. | ||
| 3 | + | ||
| 4 | + The only line that names a toolkit is the backend require — the component | ||
| 5 | + below is the one from glimmer's own README." | ||
| 6 | + (:require [glimmer.ratom :as r :refer [atom]] | ||
| 7 | + [glimmer.core :as ui] | ||
| 8 | + [glimmer-vidya.core])) | ||
| 9 | + | ||
| 10 | +(defn counter [] | ||
| 11 | + (let [count (atom 0)] | ||
| 12 | + (fn [] | ||
| 13 | + [:page {:max-width 420} | ||
| 14 | + [:card {} | ||
| 15 | + [:title {:label "Counter"}] | ||
| 16 | + [:label {:label (str "Count: " @count)}] | ||
| 17 | + [:hbox {:spacing 8} | ||
| 18 | + [:button {:label "- 1" :on-click #(swap! count dec)}] | ||
| 19 | + [:button {:label "+ 1" :kind :primary :on-click #(swap! count inc)}] | ||
| 20 | + [:button {:label "reset" :on-click #(reset! count 0)}]]]]))) | ||
| 21 | + | ||
| 22 | +(defn -main [& _] | ||
| 23 | + (ui/run counter :title "counter" :width 480 :height 320)) | ||
added
jolt/glimmer-vidya/examples/glimmer_vidya/showcase.jolt +77 -0 | new file mode 100644 | ||
| @@ -0,0 +1,77 @@ | ||
| 1 | +(ns glimmer-vidya.showcase | |
| 2 | + "Every tag this backend paints, in one scrolling page. | |
| 3 | + | |
| 4 | + It is also the reconciler's exercise: the task list is a keyed list that is | |
| 5 | + added to, removed from and reordered, so widgets are reused and moved rather | |
| 6 | + than rebuilt." | |
| 7 | + (:require [glimmer.ratom :as r :refer [atom]] | |
| 8 | + [glimmer.core :as ui] | |
| 9 | + [glimmer-vidya.core :as vidya])) | |
| 10 | + | |
| 11 | +(defonce tasks (atom [{:id 1 :text "Wire egui into glimmer" :done true} | |
| 12 | + {:id 2 :text "Reconcile a keyed list" :done false} | |
| 13 | + {:id 3 :text "Ship it" :done false}])) | |
| 14 | +(defonce draft (atom "")) | |
| 15 | +(defonce next-id (atom 4)) | |
| 16 | +(defonce progress (atom 0.0)) | |
| 17 | +(defonce live? (atom true)) | |
| 18 | + | |
| 19 | +(defn- add-task! [] | |
| 20 | + (let [text @draft] | |
| 21 | + (when (seq text) | |
| 22 | + (swap! tasks conj {:id (swap! next-id inc) :text text :done false}) | |
| 23 | + (reset! draft "")))) | |
| 24 | + | |
| 25 | +(defn- toggle! [id] | |
| 26 | + (swap! tasks (fn [ts] (mapv #(if (= id (:id %)) (update % :done not) %) ts)))) | |
| 27 | + | |
| 28 | +(defn- drop-task! [id] | |
| 29 | + (swap! tasks (fn [ts] (filterv #(not= id (:id %)) ts)))) | |
| 30 | + | |
| 31 | +(defn task-row [task] | |
| 32 | + [:hbox {:key (:id task) :spacing 12} | |
| 33 | + [:checkbutton {:label (:text task) | |
| 34 | + :active (:done task) | |
| 35 | + :on-toggled #(toggle! (:id task))}] | |
| 36 | + [:button {:label "remove" | |
| 37 | + :kind :destructive | |
| 38 | + :on-click #(drop-task! (:id task))}]]) | |
| 39 | + | |
| 40 | +(defn app [] | |
| 41 | + [:page {:max-width 620} | |
| 42 | + [:title {:label "Vidya + glimmer"}] | |
| 43 | + [:dim-label {:label "Reactive components, painted by egui."}] | |
| 44 | + | |
| 45 | + [:card {} | |
| 46 | + [:title-2 {:label "Tasks"}] | |
| 47 | + (for [task @tasks] [task-row task]) | |
| 48 | + (when (empty? @tasks) [:dim-label {:label "Nothing left."}]) | |
| 49 | + [:separator {}] | |
| 50 | + [:hbox {:spacing 8} | |
| 51 | + [:entry {:text @draft | |
| 52 | + :width-request 380 | |
| 53 | + :placeholder "What needs doing?" | |
| 54 | + :on-change #(reset! draft %) | |
| 55 | + :on-activate add-task!}] | |
| 56 | + [:button {:label "Add" :kind :primary :on-click add-task!}]]] | |
| 57 | + | |
| 58 | + [:card {} | |
| 59 | + [:title-2 {:label "Feedback"}] | |
| 60 | + [:status {:label (if @live? "Connected" "Offline") :live @live?}] | |
| 61 | + [:progress {:value @progress :label (str (int (* 100 @progress)) "%")}] | |
| 62 | + [:hbox {:spacing 8} | |
| 63 | + [:button {:label "Toggle connection" :on-click #(swap! live? not)}] | |
| 64 | + [:button {:label "Advance" | |
| 65 | + :on-click #(swap! progress (fn [v] (if (>= v 1.0) 0.0 (+ v 0.2))))}]]] | |
| 66 | + | |
| 67 | + [:card {} | |
| 68 | + [:title-2 {:label "Disabled subtree"}] | |
| 69 | + [:vbox {:spacing 6 :sensitive false} | |
| 70 | + [:label {:label "Nothing in here answers to the pointer."}] | |
| 71 | + [:button {:label "Unreachable"}]]] | |
| 72 | + | |
| 73 | + [:hbox {:spacing 8} | |
| 74 | + [:button {:label "Quit" :on-click vidya/quit!}]]]) | |
| 75 | + | |
| 76 | +(defn -main [& _] | |
| 77 | + (ui/run app :title "glimmer-vidya showcase" :width 760 :height 720)) | |
| new file mode 100644 | |||
| @@ -0,0 +1,77 @@ | |||
| 1 | +(ns glimmer-vidya.showcase | ||
| 2 | + "Every tag this backend paints, in one scrolling page. | ||
| 3 | + | ||
| 4 | + It is also the reconciler's exercise: the task list is a keyed list that is | ||
| 5 | + added to, removed from and reordered, so widgets are reused and moved rather | ||
| 6 | + than rebuilt." | ||
| 7 | + (:require [glimmer.ratom :as r :refer [atom]] | ||
| 8 | + [glimmer.core :as ui] | ||
| 9 | + [glimmer-vidya.core :as vidya])) | ||
| 10 | + | ||
| 11 | +(defonce tasks (atom [{:id 1 :text "Wire egui into glimmer" :done true} | ||
| 12 | + {:id 2 :text "Reconcile a keyed list" :done false} | ||
| 13 | + {:id 3 :text "Ship it" :done false}])) | ||
| 14 | +(defonce draft (atom "")) | ||
| 15 | +(defonce next-id (atom 4)) | ||
| 16 | +(defonce progress (atom 0.0)) | ||
| 17 | +(defonce live? (atom true)) | ||
| 18 | + | ||
| 19 | +(defn- add-task! [] | ||
| 20 | + (let [text @draft] | ||
| 21 | + (when (seq text) | ||
| 22 | + (swap! tasks conj {:id (swap! next-id inc) :text text :done false}) | ||
| 23 | + (reset! draft "")))) | ||
| 24 | + | ||
| 25 | +(defn- toggle! [id] | ||
| 26 | + (swap! tasks (fn [ts] (mapv #(if (= id (:id %)) (update % :done not) %) ts)))) | ||
| 27 | + | ||
| 28 | +(defn- drop-task! [id] | ||
| 29 | + (swap! tasks (fn [ts] (filterv #(not= id (:id %)) ts)))) | ||
| 30 | + | ||
| 31 | +(defn task-row [task] | ||
| 32 | + [:hbox {:key (:id task) :spacing 12} | ||
| 33 | + [:checkbutton {:label (:text task) | ||
| 34 | + :active (:done task) | ||
| 35 | + :on-toggled #(toggle! (:id task))}] | ||
| 36 | + [:button {:label "remove" | ||
| 37 | + :kind :destructive | ||
| 38 | + :on-click #(drop-task! (:id task))}]]) | ||
| 39 | + | ||
| 40 | +(defn app [] | ||
| 41 | + [:page {:max-width 620} | ||
| 42 | + [:title {:label "Vidya + glimmer"}] | ||
| 43 | + [:dim-label {:label "Reactive components, painted by egui."}] | ||
| 44 | + | ||
| 45 | + [:card {} | ||
| 46 | + [:title-2 {:label "Tasks"}] | ||
| 47 | + (for [task @tasks] [task-row task]) | ||
| 48 | + (when (empty? @tasks) [:dim-label {:label "Nothing left."}]) | ||
| 49 | + [:separator {}] | ||
| 50 | + [:hbox {:spacing 8} | ||
| 51 | + [:entry {:text @draft | ||
| 52 | + :width-request 380 | ||
| 53 | + :placeholder "What needs doing?" | ||
| 54 | + :on-change #(reset! draft %) | ||
| 55 | + :on-activate add-task!}] | ||
| 56 | + [:button {:label "Add" :kind :primary :on-click add-task!}]]] | ||
| 57 | + | ||
| 58 | + [:card {} | ||
| 59 | + [:title-2 {:label "Feedback"}] | ||
| 60 | + [:status {:label (if @live? "Connected" "Offline") :live @live?}] | ||
| 61 | + [:progress {:value @progress :label (str (int (* 100 @progress)) "%")}] | ||
| 62 | + [:hbox {:spacing 8} | ||
| 63 | + [:button {:label "Toggle connection" :on-click #(swap! live? not)}] | ||
| 64 | + [:button {:label "Advance" | ||
| 65 | + :on-click #(swap! progress (fn [v] (if (>= v 1.0) 0.0 (+ v 0.2))))}]]] | ||
| 66 | + | ||
| 67 | + [:card {} | ||
| 68 | + [:title-2 {:label "Disabled subtree"}] | ||
| 69 | + [:vbox {:spacing 6 :sensitive false} | ||
| 70 | + [:label {:label "Nothing in here answers to the pointer."}] | ||
| 71 | + [:button {:label "Unreachable"}]]] | ||
| 72 | + | ||
| 73 | + [:hbox {:spacing 8} | ||
| 74 | + [:button {:label "Quit" :on-click vidya/quit!}]]]) | ||
| 75 | + | ||
| 76 | +(defn -main [& _] | ||
| 77 | + (ui/run app :title "glimmer-vidya showcase" :width 760 :height 720)) | ||
added
jolt/glimmer-vidya/examples/glimmer_vidya/smoke.jolt +36 -0 | new file mode 100644 | ||
| @@ -0,0 +1,36 @@ | ||
| 1 | +(ns glimmer-vidya.smoke | |
| 2 | + "Non-interactive check against a real window. | |
| 3 | + | |
| 4 | + Mounts a tree, drives the reconciler from a timer so widgets are created, | |
| 5 | + patched, reordered and removed while frames are being painted, then quits on | |
| 6 | + its own. `VIDYA_CAPTURE=<path>` writes a frame out as a PPM, which is how a | |
| 7 | + rendering backend gets verified where nobody can look at the screen: | |
| 8 | + | |
| 9 | + VIDYA_CAPTURE=/tmp/frame.ppm LD_LIBRARY_PATH=../build jolt smoke" | |
| 10 | + (:require [glimmer.ratom :as r :refer [atom]] | |
| 11 | + [glimmer.core :as ui] | |
| 12 | + [glimmer-vidya.core :as vidya])) | |
| 13 | + | |
| 14 | +(defonce items (atom [:a :b :c])) | |
| 15 | +(defonce ticks (atom 0)) | |
| 16 | + | |
| 17 | +(defn app [] | |
| 18 | + [:page {:max-width 480} | |
| 19 | + [:title {:label "smoke"}] | |
| 20 | + [:label {:label (str "tick " @ticks)}] | |
| 21 | + [:card {} | |
| 22 | + (for [item @items] | |
| 23 | + ^{:key item} [:label {:label (str item)}])]]) | |
| 24 | + | |
| 25 | +(defn -main [& _] | |
| 26 | + ;; Reverse the list, drop one, put it back: reorder, remove and create, all | |
| 27 | + ;; against a tree that is being painted at the same time. | |
| 28 | + (vidya/every! 200 (fn [] | |
| 29 | + (swap! ticks inc) | |
| 30 | + (swap! items (fn [xs] | |
| 31 | + (case (mod @ticks 3) | |
| 32 | + 0 (vec (reverse xs)) | |
| 33 | + 1 (vec (rest xs)) | |
| 34 | + (vec (cons :a xs))))))) | |
| 35 | + (ui/run app :title "smoke" :width 480 :height 360 :auto-quit-ms 3000) | |
| 36 | + (println "smoke: ok, painted" @ticks "ticks")) | |
| new file mode 100644 | |||
| @@ -0,0 +1,36 @@ | |||
| 1 | +(ns glimmer-vidya.smoke | ||
| 2 | + "Non-interactive check against a real window. | ||
| 3 | + | ||
| 4 | + Mounts a tree, drives the reconciler from a timer so widgets are created, | ||
| 5 | + patched, reordered and removed while frames are being painted, then quits on | ||
| 6 | + its own. `VIDYA_CAPTURE=<path>` writes a frame out as a PPM, which is how a | ||
| 7 | + rendering backend gets verified where nobody can look at the screen: | ||
| 8 | + | ||
| 9 | + VIDYA_CAPTURE=/tmp/frame.ppm LD_LIBRARY_PATH=../build jolt smoke" | ||
| 10 | + (:require [glimmer.ratom :as r :refer [atom]] | ||
| 11 | + [glimmer.core :as ui] | ||
| 12 | + [glimmer-vidya.core :as vidya])) | ||
| 13 | + | ||
| 14 | +(defonce items (atom [:a :b :c])) | ||
| 15 | +(defonce ticks (atom 0)) | ||
| 16 | + | ||
| 17 | +(defn app [] | ||
| 18 | + [:page {:max-width 480} | ||
| 19 | + [:title {:label "smoke"}] | ||
| 20 | + [:label {:label (str "tick " @ticks)}] | ||
| 21 | + [:card {} | ||
| 22 | + (for [item @items] | ||
| 23 | + ^{:key item} [:label {:label (str item)}])]]) | ||
| 24 | + | ||
| 25 | +(defn -main [& _] | ||
| 26 | + ;; Reverse the list, drop one, put it back: reorder, remove and create, all | ||
| 27 | + ;; against a tree that is being painted at the same time. | ||
| 28 | + (vidya/every! 200 (fn [] | ||
| 29 | + (swap! ticks inc) | ||
| 30 | + (swap! items (fn [xs] | ||
| 31 | + (case (mod @ticks 3) | ||
| 32 | + 0 (vec (reverse xs)) | ||
| 33 | + 1 (vec (rest xs)) | ||
| 34 | + (vec (cons :a xs))))))) | ||
| 35 | + (ui/run app :title "smoke" :width 480 :height 360 :auto-quit-ms 3000) | ||
| 36 | + (println "smoke: ok, painted" @ticks "ticks")) | ||
added
jolt/glimmer-vidya/src/glimmer_vidya/core.jolt +407 -0 | new file mode 100644 | ||
| @@ -0,0 +1,407 @@ | ||
| 1 | +(ns glimmer-vidya.core | |
| 2 | + "The Vidya/egui backend for glimmer. Requiring this namespace installs it, | |
| 3 | + after which glimmer's portable reconciler renders hiccup into a GPU window: | |
| 4 | + | |
| 5 | + (ns myapp | |
| 6 | + (:require [glimmer.ratom :refer [atom]] | |
| 7 | + [glimmer.core :as ui] | |
| 8 | + [glimmer-vidya.core])) ; installs this backend | |
| 9 | + | |
| 10 | + (defn -main [& _] (ui/run my-app :title \"myapp\")) | |
| 11 | + | |
| 12 | + egui is an immediate-mode toolkit: it has no widgets to hand a reconciler, | |
| 13 | + only calls you make every frame. So the widget tree lives one layer down, in | |
| 14 | + the Rust library, and this namespace is the thin part — it turns glimmer's | |
| 15 | + create/patch/append/remove into node mutations, runs the frame loop, and | |
| 16 | + routes the events that come back to the handlers the components declared. | |
| 17 | + | |
| 18 | + A widget, from the reconciler's point of view, is an integer node handle. It | |
| 19 | + never looks inside one, which is exactly why an id is enough. | |
| 20 | + | |
| 21 | + **Handlers do not cross the FFI.** A jolt closure cannot be a callback in a | |
| 22 | + library painting at 60fps, so identity travels instead: a node reports that | |
| 23 | + it was clicked, and the handler map here says whose `:on-click` that was." | |
| 24 | + (:require [glimmer.backend :as b] | |
| 25 | + [glimmer-vidya.ffi :as ffi])) | |
| 26 | + | |
| 27 | +;; Node id -> the :on-* props that node was last rendered with. Kept here | |
| 28 | +;; rather than sent across because a closure has no C representation. | |
| 29 | +;; | |
| 30 | +;; Ids are recycled by the arena, which is safe only because every id is | |
| 31 | +;; written here by `create!` before anything can raise an event against it — a | |
| 32 | +;; reused id has its predecessor's handlers overwritten in the same breath. | |
| 33 | +(defonce ^:private handlers (atom {})) | |
| 34 | + | |
| 35 | +;; Work posted from other threads, run on the loop thread at the top of a tick. | |
| 36 | +(defonce ^:private pending (atom [])) | |
| 37 | + | |
| 38 | +;; Set by quit!, read by the loop. | |
| 39 | +(defonce ^:private quit-requested (atom false)) | |
| 40 | + | |
| 41 | +;; --- props ------------------------------------------------------------------- | |
| 42 | +;; :hbox and :vbox are one node in the library; the tag only implies an | |
| 43 | +;; orientation, and an explicit :orientation prop still wins. | |
| 44 | +(def ^:private tag-orientation {:hbox "horizontal" :vbox "vertical"}) | |
| 45 | + | |
| 46 | +(defn- handler-key? | |
| 47 | + "True for a prop that names an event handler rather than a value." | |
| 48 | + [k] | |
| 49 | + (let [s (name k)] | |
| 50 | + (and (> (count s) 3) (= "on-" (subs s 0 3))))) | |
| 51 | + | |
| 52 | +(defn- set-prop! | |
| 53 | + "Write one prop to a node, in the ABI type that fits its value. nil clears | |
| 54 | + nothing — the prop was already dropped by the clear that precedes a write — | |
| 55 | + and an unrecognized value is stringified rather than refused, so a prop this | |
| 56 | + backend has not learned yet still reaches the library." | |
| 57 | + [node k v] | |
| 58 | + (let [key (name k)] | |
| 59 | + (cond | |
| 60 | + (nil? v) nil | |
| 61 | + (true? v) (ffi/node-set-bool! node key true) | |
| 62 | + (false? v) (ffi/node-set-bool! node key false) | |
| 63 | + (number? v) (ffi/node-set-num! node key (double v)) | |
| 64 | + (string? v) (ffi/node-set-str! node key v) | |
| 65 | + (keyword? v) (ffi/node-set-str! node key (name v)) | |
| 66 | + :else (ffi/node-set-str! node key (str v))))) | |
| 67 | + | |
| 68 | +(defn- write-props! | |
| 69 | + "Replace a node's props with `props`. | |
| 70 | + | |
| 71 | + Cleared first, deliberately: a re-render that stops setting `:placeholder` | |
| 72 | + means the placeholder is gone, and patching in place would leave the old one | |
| 73 | + behind. It also discards the value the library wrote back when the user typed | |
| 74 | + in an entry or clicked a checkbutton — which is the point. The component's | |
| 75 | + state is the truth, and this is the frame where it says so." | |
| 76 | + [node tag props] | |
| 77 | + (ffi/node-clear-props! node) | |
| 78 | + (when-let [orientation (tag-orientation tag)] | |
| 79 | + (when-not (contains? props :orientation) | |
| 80 | + (ffi/node-set-str! node "orientation" orientation))) | |
| 81 | + (doseq [[k v] props] | |
| 82 | + (when-not (handler-key? k) | |
| 83 | + (set-prop! node k v))) | |
| 84 | + (swap! handlers assoc node | |
| 85 | + (reduce (fn [acc [k v]] | |
| 86 | + (if (and (handler-key? k) (fn? v)) (assoc acc k v) acc)) | |
| 87 | + {} | |
| 88 | + props)) | |
| 89 | + nil) | |
| 90 | + | |
| 91 | +(defn- forget-dead-handlers! | |
| 92 | + "Drop handler entries for nodes the library has freed. | |
| 93 | + | |
| 94 | + Removing a subtree frees every node under it, and only the library knows | |
| 95 | + which those were — so rather than mirror the tree here to walk it, the map is | |
| 96 | + filtered against what still exists. It runs once per removal, over a map the | |
| 97 | + size of the UI." | |
| 98 | + [] | |
| 99 | + (swap! handlers | |
| 100 | + (fn [m] | |
| 101 | + (reduce (fn [acc [id hs]] | |
| 102 | + (if (ffi/node-exists? id) (assoc acc id hs) acc)) | |
| 103 | + {} | |
| 104 | + m))) | |
| 105 | + nil) | |
| 106 | + | |
| 107 | +;; --- the backend operations -------------------------------------------------- | |
| 108 | +(defn- create! | |
| 109 | + "glimmer.backend's :create!. Children are appended by the reconciler, not | |
| 110 | + here." | |
| 111 | + [tag props] | |
| 112 | + (let [node (ffi/node-new (name tag))] | |
| 113 | + (when (zero? node) | |
| 114 | + (throw (ex-info "vidya could not allocate a node" {:tag tag}))) | |
| 115 | + (write-props! node tag props) | |
| 116 | + node)) | |
| 117 | + | |
| 118 | +(defn- apply-props! [tag node props] (write-props! node tag props)) | |
| 119 | + | |
| 120 | +(defn- append-child! [_parent-tag parent child] | |
| 121 | + (ffi/node-append! parent child) | |
| 122 | + nil) | |
| 123 | + | |
| 124 | +(defn- remove-child! [_parent-tag parent child] | |
| 125 | + ;; The library frees the subtree; glimmer never mentions it again. | |
| 126 | + (ffi/node-remove! parent child) | |
| 127 | + (forget-dead-handlers!) | |
| 128 | + nil) | |
| 129 | + | |
| 130 | +(defn- replace-child! [_parent-tag parent old-child new-child] | |
| 131 | + (ffi/node-replace! parent old-child new-child) | |
| 132 | + (forget-dead-handlers!) | |
| 133 | + nil) | |
| 134 | + | |
| 135 | +(defn- reorder-child! [_parent-tag parent child sibling] | |
| 136 | + ;; nil sibling means "first"; the ABI spells that 0. | |
| 137 | + (ffi/node-insert-after! parent child (or sibling 0)) | |
| 138 | + nil) | |
| 139 | + | |
| 140 | +;; --- the UI thread ----------------------------------------------------------- | |
| 141 | +(defn- schedule | |
| 142 | + "glimmer.backend's :schedule. Every node call belongs to the thread that | |
| 143 | + opened the window, so a ratom mutated on an nREPL worker (or any future) | |
| 144 | + queues its re-render here and the loop performs it on the next tick." | |
| 145 | + [work] | |
| 146 | + (swap! pending conj work) | |
| 147 | + nil) | |
| 148 | + | |
| 149 | +(defn- drain! | |
| 150 | + "Run everything `schedule` queued. compare-and-set! rather than reset!, so | |
| 151 | + work posted while the queue is being taken is not dropped." | |
| 152 | + [] | |
| 153 | + (loop [] | |
| 154 | + (let [q @pending] | |
| 155 | + (when (seq q) | |
| 156 | + (if (compare-and-set! pending q []) | |
| 157 | + (doseq [f q] (f)) | |
| 158 | + (recur)))))) | |
| 159 | + | |
| 160 | +;; --- timers ------------------------------------------------------------------ | |
| 161 | +;; A spinner or a clock has to change with nothing being pressed. The loop | |
| 162 | +;; already wakes every frame, so a timer is a due time and a thunk. Both | |
| 163 | +;; entry points are safe to call from another thread, and both run their thunk | |
| 164 | +;; ON the loop thread, the only one allowed to touch nodes. | |
| 165 | +(defonce ^:private timers (atom {:next-id 0 :entries {}})) | |
| 166 | + | |
| 167 | +(defn- now-ms [] (System/currentTimeMillis)) | |
| 168 | + | |
| 169 | +(defn- add-timer! [ms every? f] | |
| 170 | + (let [id (:next-id (swap! timers update :next-id inc))] | |
| 171 | + (swap! timers assoc-in [:entries id] | |
| 172 | + {:due (+ (now-ms) ms) :every (when every? ms) :f f}) | |
| 173 | + id)) | |
| 174 | + | |
| 175 | +(defn after! | |
| 176 | + "Run `f` on the loop thread in about `ms` milliseconds. Returns an id for | |
| 177 | + `cancel!`. Resolution is one frame." | |
| 178 | + [ms f] (add-timer! ms false f)) | |
| 179 | + | |
| 180 | +(defn every! | |
| 181 | + "Run `f` on the loop thread about every `ms` milliseconds until cancelled: | |
| 182 | + | |
| 183 | + (every! 80 #(swap! tick inc))" | |
| 184 | + [ms f] (add-timer! ms true f)) | |
| 185 | + | |
| 186 | +(defn cancel! | |
| 187 | + "Stop the timer `id`." | |
| 188 | + [id] (swap! timers update :entries dissoc id) nil) | |
| 189 | + | |
| 190 | +(defn cancel-all! | |
| 191 | + "Stop every timer, so a repeating one does not outlive the UI it animated." | |
| 192 | + [] (swap! timers assoc :entries {}) nil) | |
| 193 | + | |
| 194 | +(defn- pump-timers! [] | |
| 195 | + (let [t (now-ms) | |
| 196 | + due (reduce (fn [acc [id e]] (if (<= (:due e) t) (conj acc [id e]) acc)) | |
| 197 | + [] | |
| 198 | + (:entries @timers))] | |
| 199 | + (doseq [[id e] due] | |
| 200 | + (if-let [period (:every e)] | |
| 201 | + (swap! timers assoc-in [:entries id :due] (+ t period)) | |
| 202 | + (swap! timers update :entries dissoc id)) | |
| 203 | + ((:f e))) | |
| 204 | + nil)) | |
| 205 | + | |
| 206 | +;; --- events ------------------------------------------------------------------ | |
| 207 | +(defn- dispatch-events! | |
| 208 | + "Drain the frame's interactions and call the handlers they belong to. | |
| 209 | + | |
| 210 | + An event whose node has no handler for it is dropped, which is what makes a | |
| 211 | + control that ignores its own event still work: the library wrote the new | |
| 212 | + state into the node, and the next render either confirms it or overwrites it." | |
| 213 | + [] | |
| 214 | + (loop [] | |
| 215 | + (when (ffi/poll-event!) | |
| 216 | + (let [node (ffi/event-node) | |
| 217 | + kind (ffi/event-name) | |
| 218 | + hs (get @handlers node)] | |
| 219 | + (case kind | |
| 220 | + "click" (when-let [f (:on-click hs)] (f)) | |
| 221 | + "toggled" (when-let [f (:on-toggled hs)] (f)) | |
| 222 | + ;; The text is read before anything else can overwrite the library's | |
| 223 | + ;; one scratch buffer — jolt copies it as it crosses. | |
| 224 | + "change" (when-let [f (:on-change hs)] (f (ffi/event-text))) | |
| 225 | + "activate" (when-let [f (:on-activate hs)] (f)) | |
| 226 | + ;; Ctrl+V on a clipboard with no text on it. What is on it instead is | |
| 227 | + ;; the caller's to find out — `clipboard-image-png!` is the only | |
| 228 | + ;; question the backend answers about it. | |
| 229 | + "paste-empty" (when-let [f (:on-paste-empty hs)] (f)) | |
| 230 | + nil)) | |
| 231 | + (recur)))) | |
| 232 | + | |
| 233 | +;; --- the event loop ---------------------------------------------------------- | |
| 234 | +(defn- clear-children! | |
| 235 | + "Drop everything under `node`. Removing a child frees it, so this walks the | |
| 236 | + first slot until there is nothing left rather than iterating an index." | |
| 237 | + [node] | |
| 238 | + (loop [] | |
| 239 | + (when (pos? (ffi/node-child-count node)) | |
| 240 | + (ffi/node-remove! node (ffi/node-child-at node 0)) | |
| 241 | + (recur))) | |
| 242 | + (forget-dead-handlers!) | |
| 243 | + nil) | |
| 244 | + | |
| 245 | +(defn window-width | |
| 246 | + "The width in points of the window's content area, as the last painted frame | |
| 247 | + measured it. 0 before the first frame. | |
| 248 | + | |
| 249 | + This is how a layout asks how much room it has: nothing else here reports a | |
| 250 | + size, and the window is not resizable from this side either. Read it from a | |
| 251 | + timer — `every!` — rather than per render, and hold it in a ratom, so that | |
| 252 | + the components which switch on it re-render only when it actually changes." | |
| 253 | + [] | |
| 254 | + (ffi/node-get-num (ffi/tree-root) "window-width")) | |
| 255 | + | |
| 256 | +(defn clipboard-image-png! | |
| 257 | + "Write the picture on the system clipboard to `path` as a PNG. True when | |
| 258 | + there was one; false for an empty clipboard, text on it, an unwritable path, | |
| 259 | + or a platform with no clipboard of images (Android). | |
| 260 | + | |
| 261 | + A paste of a picture reaches no handler of its own — the backend delivers | |
| 262 | + clipboard text only — so a caller asks for it, from whatever gesture it means | |
| 263 | + paste by. An `:entry`'s `:on-paste-empty` is that gesture where the reader | |
| 264 | + expects it: a Ctrl+V the field had no text to answer with." | |
| 265 | + [path] | |
| 266 | + (ffi/clipboard-image-png! path)) | |
| 267 | + | |
| 268 | +(defn open-url! | |
| 269 | + "Hand `url` to whatever shows web pages here — xdg-open or `open` on a | |
| 270 | + desktop, an ACTION_VIEW intent on Android. True when something took it; | |
| 271 | + false leaves the caller to show the URL and let the reader carry it across. | |
| 272 | + | |
| 273 | + A sign-in that goes through a browser is the reason this exists: the app | |
| 274 | + leaves for a page and the page comes back to it." | |
| 275 | + [url] | |
| 276 | + (ffi/open-url! url)) | |
| 277 | + | |
| 278 | +(defn frame-rgba! | |
| 279 | + "Hand the backend a frame of live pixels under `key`. An `:image` node with | |
| 280 | + `:feed key` paints the latest one. | |
| 281 | + | |
| 282 | + `rgba` is a foreign pointer — `width * height * 4` un-premultiplied bytes, | |
| 283 | + row-major — and is copied before this returns, so whoever owns it may reuse | |
| 284 | + it immediately. False when the length does not match the dimensions. | |
| 285 | + | |
| 286 | + This is the source an `:image` `:src` cannot be: a `:src` decodes a file and | |
| 287 | + caches it by path for the life of the process, which is right for a picture | |
| 288 | + in a message and wrong for one that is new thirty times a second. Frames | |
| 289 | + coalesce rather than queue, so a source faster than the window costs nothing. | |
| 290 | + | |
| 291 | + On the window's thread, like everything else here: a frame produced on a | |
| 292 | + decoder thread is the caller's to hand across." | |
| 293 | + [key width height rgba] | |
| 294 | + (ffi/frame-rgba! key width height rgba)) | |
| 295 | + | |
| 296 | +(defn frame-drop! | |
| 297 | + "Forget the feed named `key` and release its texture; true when there was | |
| 298 | + one. A source that has stopped keeps painting its last frame otherwise." | |
| 299 | + [key] | |
| 300 | + (ffi/frame-drop! key)) | |
| 301 | + | |
| 302 | +(defn quit! | |
| 303 | + "Stop the running loop and close the window." | |
| 304 | + [] | |
| 305 | + (reset! quit-requested true) | |
| 306 | + nil) | |
| 307 | + | |
| 308 | +(defn- run! | |
| 309 | + "glimmer.backend's :run. Opens a window, mounts the root component into the | |
| 310 | + library's root node, and paints until the window closes or `quit!` is called. | |
| 311 | + Blocks, like every UI main loop. | |
| 312 | + | |
| 313 | + Options (on top of glimmer's own): | |
| 314 | + :title :width :height the window | |
| 315 | + :fps frame rate cap (default 60) | |
| 316 | + :mode :dark (default) or :light | |
| 317 | + :font path to a TTF/OTF to use for UI text | |
| 318 | + :auto-quit-ms stop after roughly this long — for smoke tests, | |
| 319 | + which have nobody to close the window | |
| 320 | + | |
| 321 | + The window is closed in a finally, so a handler that throws does not leave a | |
| 322 | + GPU surface and an event loop behind." | |
| 323 | + [opts mount-root!] | |
| 324 | + (let [{:keys [title width height fps mode font auto-quit-ms] | |
| 325 | + :or {title "glimmer" width 900 height 640 fps 60}} opts] | |
| 326 | + (when-not (ffi/open! width height title) | |
| 327 | + (throw (ex-info "vidya could not open a window" | |
| 328 | + {:title title :width width :height height}))) | |
| 329 | + (reset! quit-requested false) | |
| 330 | + (ffi/set-target-fps! fps) | |
| 331 | + (ffi/set-mode! (if (= mode :light) ffi/light-mode ffi/dark-mode)) | |
| 332 | + (when font (ffi/load-font! font)) | |
| 333 | + (let [started (now-ms) | |
| 334 | + root (ffi/tree-root)] | |
| 335 | + (try | |
| 336 | + ;; The library's root outlives a run — it is process-wide, not per | |
| 337 | + ;; window — so a second `ui/run` in one session (a REPL, a test) would | |
| 338 | + ;; otherwise mount its tree alongside the last one's. | |
| 339 | + (clear-children! root) | |
| 340 | + (mount-root! root :window) | |
| 341 | + (reset! b/loop-running? true) | |
| 342 | + (loop [] | |
| 343 | + (drain!) | |
| 344 | + (pump-timers!) | |
| 345 | + ;; One call paints the whole tree; the events it produced are read | |
| 346 | + ;; straight after, while the frame that caused them is still the | |
| 347 | + ;; frame the components rendered. | |
| 348 | + (ffi/tree-frame!) | |
| 349 | + (dispatch-events!) | |
| 350 | + (when-not (or @quit-requested | |
| 351 | + (ffi/should-close?) | |
| 352 | + (and auto-quit-ms (>= (- (now-ms) started) auto-quit-ms))) | |
| 353 | + (recur))) | |
| 354 | + (finally | |
| 355 | + (reset! b/loop-running? false) | |
| 356 | + (cancel-all!) | |
| 357 | + (ffi/close!) | |
| 358 | + (reset! handlers {})))))) | |
| 359 | + | |
| 360 | +;; --- looking at what was rendered -------------------------------------------- | |
| 361 | +(defn dump-str | |
| 362 | + "The rendered tree as hiccup text, read back out of the library. | |
| 363 | + | |
| 364 | + With no argument, the whole window; with a node handle, that subtree. This is | |
| 365 | + the tree as it *is* after the reconciler has run, not what a component | |
| 366 | + returned, so paste it into a bug report and the two can be compared. | |
| 367 | + | |
| 368 | + Two things to know when reading one. `:hbox` and `:vbox` are one node down | |
| 369 | + there and both dump as `:box`, with the orientation in the props; and no | |
| 370 | + `:on-*` appears, because handlers are held on this side and never sent." | |
| 371 | + ([] (dump-str 0)) | |
| 372 | + ([node] (ffi/tree-dump node))) | |
| 373 | + | |
| 374 | +(defn dump | |
| 375 | + "`dump-str`, read back as hiccup data — vectors, keywords and maps — for a | |
| 376 | + test that wants to assert on a subtree rather than on a string." | |
| 377 | + ([] (dump 0)) | |
| 378 | + ([node] (read-string (dump-str node)))) | |
| 379 | + | |
| 380 | +(defn dump! | |
| 381 | + "Print `dump-str` to stdout. The one you want from a handler or the REPL." | |
| 382 | + ([] (dump! 0)) | |
| 383 | + ([node] (println (dump-str node)) nil)) | |
| 384 | + | |
| 385 | +;; --- the backend ------------------------------------------------------------- | |
| 386 | +(def backend | |
| 387 | + "The Vidya backend map handed to glimmer.backend/register!. See that | |
| 388 | + namespace for the contract each key satisfies." | |
| 389 | + {:name :vidya | |
| 390 | + :create! create! | |
| 391 | + :apply-props! apply-props! | |
| 392 | + :append-child! append-child! | |
| 393 | + :remove-child! remove-child! | |
| 394 | + :replace-child! replace-child! | |
| 395 | + :reorder-child! reorder-child! | |
| 396 | + :schedule schedule | |
| 397 | + :run run!}) | |
| 398 | + | |
| 399 | +(defn install! | |
| 400 | + "Make Vidya/egui the backend glimmer renders with. Called on load, so | |
| 401 | + requiring this namespace is enough; exposed for code that wants to be | |
| 402 | + explicit, or to switch back after another backend was installed." | |
| 403 | + [] | |
| 404 | + (b/register! backend) | |
| 405 | + nil) | |
| 406 | + | |
| 407 | +(defonce ^:private installed (do (install!) true)) | |
| new file mode 100644 | |||
| @@ -0,0 +1,407 @@ | |||
| 1 | +(ns glimmer-vidya.core | ||
| 2 | + "The Vidya/egui backend for glimmer. Requiring this namespace installs it, | ||
| 3 | + after which glimmer's portable reconciler renders hiccup into a GPU window: | ||
| 4 | + | ||
| 5 | + (ns myapp | ||
| 6 | + (:require [glimmer.ratom :refer [atom]] | ||
| 7 | + [glimmer.core :as ui] | ||
| 8 | + [glimmer-vidya.core])) ; installs this backend | ||
| 9 | + | ||
| 10 | + (defn -main [& _] (ui/run my-app :title \"myapp\")) | ||
| 11 | + | ||
| 12 | + egui is an immediate-mode toolkit: it has no widgets to hand a reconciler, | ||
| 13 | + only calls you make every frame. So the widget tree lives one layer down, in | ||
| 14 | + the Rust library, and this namespace is the thin part — it turns glimmer's | ||
| 15 | + create/patch/append/remove into node mutations, runs the frame loop, and | ||
| 16 | + routes the events that come back to the handlers the components declared. | ||
| 17 | + | ||
| 18 | + A widget, from the reconciler's point of view, is an integer node handle. It | ||
| 19 | + never looks inside one, which is exactly why an id is enough. | ||
| 20 | + | ||
| 21 | + **Handlers do not cross the FFI.** A jolt closure cannot be a callback in a | ||
| 22 | + library painting at 60fps, so identity travels instead: a node reports that | ||
| 23 | + it was clicked, and the handler map here says whose `:on-click` that was." | ||
| 24 | + (:require [glimmer.backend :as b] | ||
| 25 | + [glimmer-vidya.ffi :as ffi])) | ||
| 26 | + | ||
| 27 | +;; Node id -> the :on-* props that node was last rendered with. Kept here | ||
| 28 | +;; rather than sent across because a closure has no C representation. | ||
| 29 | +;; | ||
| 30 | +;; Ids are recycled by the arena, which is safe only because every id is | ||
| 31 | +;; written here by `create!` before anything can raise an event against it — a | ||
| 32 | +;; reused id has its predecessor's handlers overwritten in the same breath. | ||
| 33 | +(defonce ^:private handlers (atom {})) | ||
| 34 | + | ||
| 35 | +;; Work posted from other threads, run on the loop thread at the top of a tick. | ||
| 36 | +(defonce ^:private pending (atom [])) | ||
| 37 | + | ||
| 38 | +;; Set by quit!, read by the loop. | ||
| 39 | +(defonce ^:private quit-requested (atom false)) | ||
| 40 | + | ||
| 41 | +;; --- props ------------------------------------------------------------------- | ||
| 42 | +;; :hbox and :vbox are one node in the library; the tag only implies an | ||
| 43 | +;; orientation, and an explicit :orientation prop still wins. | ||
| 44 | +(def ^:private tag-orientation {:hbox "horizontal" :vbox "vertical"}) | ||
| 45 | + | ||
| 46 | +(defn- handler-key? | ||
| 47 | + "True for a prop that names an event handler rather than a value." | ||
| 48 | + [k] | ||
| 49 | + (let [s (name k)] | ||
| 50 | + (and (> (count s) 3) (= "on-" (subs s 0 3))))) | ||
| 51 | + | ||
| 52 | +(defn- set-prop! | ||
| 53 | + "Write one prop to a node, in the ABI type that fits its value. nil clears | ||
| 54 | + nothing — the prop was already dropped by the clear that precedes a write — | ||
| 55 | + and an unrecognized value is stringified rather than refused, so a prop this | ||
| 56 | + backend has not learned yet still reaches the library." | ||
| 57 | + [node k v] | ||
| 58 | + (let [key (name k)] | ||
| 59 | + (cond | ||
| 60 | + (nil? v) nil | ||
| 61 | + (true? v) (ffi/node-set-bool! node key true) | ||
| 62 | + (false? v) (ffi/node-set-bool! node key false) | ||
| 63 | + (number? v) (ffi/node-set-num! node key (double v)) | ||
| 64 | + (string? v) (ffi/node-set-str! node key v) | ||
| 65 | + (keyword? v) (ffi/node-set-str! node key (name v)) | ||
| 66 | + :else (ffi/node-set-str! node key (str v))))) | ||
| 67 | + | ||
| 68 | +(defn- write-props! | ||
| 69 | + "Replace a node's props with `props`. | ||
| 70 | + | ||
| 71 | + Cleared first, deliberately: a re-render that stops setting `:placeholder` | ||
| 72 | + means the placeholder is gone, and patching in place would leave the old one | ||
| 73 | + behind. It also discards the value the library wrote back when the user typed | ||
| 74 | + in an entry or clicked a checkbutton — which is the point. The component's | ||
| 75 | + state is the truth, and this is the frame where it says so." | ||
| 76 | + [node tag props] | ||
| 77 | + (ffi/node-clear-props! node) | ||
| 78 | + (when-let [orientation (tag-orientation tag)] | ||
| 79 | + (when-not (contains? props :orientation) | ||
| 80 | + (ffi/node-set-str! node "orientation" orientation))) | ||
| 81 | + (doseq [[k v] props] | ||
| 82 | + (when-not (handler-key? k) | ||
| 83 | + (set-prop! node k v))) | ||
| 84 | + (swap! handlers assoc node | ||
| 85 | + (reduce (fn [acc [k v]] | ||
| 86 | + (if (and (handler-key? k) (fn? v)) (assoc acc k v) acc)) | ||
| 87 | + {} | ||
| 88 | + props)) | ||
| 89 | + nil) | ||
| 90 | + | ||
| 91 | +(defn- forget-dead-handlers! | ||
| 92 | + "Drop handler entries for nodes the library has freed. | ||
| 93 | + | ||
| 94 | + Removing a subtree frees every node under it, and only the library knows | ||
| 95 | + which those were — so rather than mirror the tree here to walk it, the map is | ||
| 96 | + filtered against what still exists. It runs once per removal, over a map the | ||
| 97 | + size of the UI." | ||
| 98 | + [] | ||
| 99 | + (swap! handlers | ||
| 100 | + (fn [m] | ||
| 101 | + (reduce (fn [acc [id hs]] | ||
| 102 | + (if (ffi/node-exists? id) (assoc acc id hs) acc)) | ||
| 103 | + {} | ||
| 104 | + m))) | ||
| 105 | + nil) | ||
| 106 | + | ||
| 107 | +;; --- the backend operations -------------------------------------------------- | ||
| 108 | +(defn- create! | ||
| 109 | + "glimmer.backend's :create!. Children are appended by the reconciler, not | ||
| 110 | + here." | ||
| 111 | + [tag props] | ||
| 112 | + (let [node (ffi/node-new (name tag))] | ||
| 113 | + (when (zero? node) | ||
| 114 | + (throw (ex-info "vidya could not allocate a node" {:tag tag}))) | ||
| 115 | + (write-props! node tag props) | ||
| 116 | + node)) | ||
| 117 | + | ||
| 118 | +(defn- apply-props! [tag node props] (write-props! node tag props)) | ||
| 119 | + | ||
| 120 | +(defn- append-child! [_parent-tag parent child] | ||
| 121 | + (ffi/node-append! parent child) | ||
| 122 | + nil) | ||
| 123 | + | ||
| 124 | +(defn- remove-child! [_parent-tag parent child] | ||
| 125 | + ;; The library frees the subtree; glimmer never mentions it again. | ||
| 126 | + (ffi/node-remove! parent child) | ||
| 127 | + (forget-dead-handlers!) | ||
| 128 | + nil) | ||
| 129 | + | ||
| 130 | +(defn- replace-child! [_parent-tag parent old-child new-child] | ||
| 131 | + (ffi/node-replace! parent old-child new-child) | ||
| 132 | + (forget-dead-handlers!) | ||
| 133 | + nil) | ||
| 134 | + | ||
| 135 | +(defn- reorder-child! [_parent-tag parent child sibling] | ||
| 136 | + ;; nil sibling means "first"; the ABI spells that 0. | ||
| 137 | + (ffi/node-insert-after! parent child (or sibling 0)) | ||
| 138 | + nil) | ||
| 139 | + | ||
| 140 | +;; --- the UI thread ----------------------------------------------------------- | ||
| 141 | +(defn- schedule | ||
| 142 | + "glimmer.backend's :schedule. Every node call belongs to the thread that | ||
| 143 | + opened the window, so a ratom mutated on an nREPL worker (or any future) | ||
| 144 | + queues its re-render here and the loop performs it on the next tick." | ||
| 145 | + [work] | ||
| 146 | + (swap! pending conj work) | ||
| 147 | + nil) | ||
| 148 | + | ||
| 149 | +(defn- drain! | ||
| 150 | + "Run everything `schedule` queued. compare-and-set! rather than reset!, so | ||
| 151 | + work posted while the queue is being taken is not dropped." | ||
| 152 | + [] | ||
| 153 | + (loop [] | ||
| 154 | + (let [q @pending] | ||
| 155 | + (when (seq q) | ||
| 156 | + (if (compare-and-set! pending q []) | ||
| 157 | + (doseq [f q] (f)) | ||
| 158 | + (recur)))))) | ||
| 159 | + | ||
| 160 | +;; --- timers ------------------------------------------------------------------ | ||
| 161 | +;; A spinner or a clock has to change with nothing being pressed. The loop | ||
| 162 | +;; already wakes every frame, so a timer is a due time and a thunk. Both | ||
| 163 | +;; entry points are safe to call from another thread, and both run their thunk | ||
| 164 | +;; ON the loop thread, the only one allowed to touch nodes. | ||
| 165 | +(defonce ^:private timers (atom {:next-id 0 :entries {}})) | ||
| 166 | + | ||
| 167 | +(defn- now-ms [] (System/currentTimeMillis)) | ||
| 168 | + | ||
| 169 | +(defn- add-timer! [ms every? f] | ||
| 170 | + (let [id (:next-id (swap! timers update :next-id inc))] | ||
| 171 | + (swap! timers assoc-in [:entries id] | ||
| 172 | + {:due (+ (now-ms) ms) :every (when every? ms) :f f}) | ||
| 173 | + id)) | ||
| 174 | + | ||
| 175 | +(defn after! | ||
| 176 | + "Run `f` on the loop thread in about `ms` milliseconds. Returns an id for | ||
| 177 | + `cancel!`. Resolution is one frame." | ||
| 178 | + [ms f] (add-timer! ms false f)) | ||
| 179 | + | ||
| 180 | +(defn every! | ||
| 181 | + "Run `f` on the loop thread about every `ms` milliseconds until cancelled: | ||
| 182 | + | ||
| 183 | + (every! 80 #(swap! tick inc))" | ||
| 184 | + [ms f] (add-timer! ms true f)) | ||
| 185 | + | ||
| 186 | +(defn cancel! | ||
| 187 | + "Stop the timer `id`." | ||
| 188 | + [id] (swap! timers update :entries dissoc id) nil) | ||
| 189 | + | ||
| 190 | +(defn cancel-all! | ||
| 191 | + "Stop every timer, so a repeating one does not outlive the UI it animated." | ||
| 192 | + [] (swap! timers assoc :entries {}) nil) | ||
| 193 | + | ||
| 194 | +(defn- pump-timers! [] | ||
| 195 | + (let [t (now-ms) | ||
| 196 | + due (reduce (fn [acc [id e]] (if (<= (:due e) t) (conj acc [id e]) acc)) | ||
| 197 | + [] | ||
| 198 | + (:entries @timers))] | ||
| 199 | + (doseq [[id e] due] | ||
| 200 | + (if-let [period (:every e)] | ||
| 201 | + (swap! timers assoc-in [:entries id :due] (+ t period)) | ||
| 202 | + (swap! timers update :entries dissoc id)) | ||
| 203 | + ((:f e))) | ||
| 204 | + nil)) | ||
| 205 | + | ||
| 206 | +;; --- events ------------------------------------------------------------------ | ||
| 207 | +(defn- dispatch-events! | ||
| 208 | + "Drain the frame's interactions and call the handlers they belong to. | ||
| 209 | + | ||
| 210 | + An event whose node has no handler for it is dropped, which is what makes a | ||
| 211 | + control that ignores its own event still work: the library wrote the new | ||
| 212 | + state into the node, and the next render either confirms it or overwrites it." | ||
| 213 | + [] | ||
| 214 | + (loop [] | ||
| 215 | + (when (ffi/poll-event!) | ||
| 216 | + (let [node (ffi/event-node) | ||
| 217 | + kind (ffi/event-name) | ||
| 218 | + hs (get @handlers node)] | ||
| 219 | + (case kind | ||
| 220 | + "click" (when-let [f (:on-click hs)] (f)) | ||
| 221 | + "toggled" (when-let [f (:on-toggled hs)] (f)) | ||
| 222 | + ;; The text is read before anything else can overwrite the library's | ||
| 223 | + ;; one scratch buffer — jolt copies it as it crosses. | ||
| 224 | + "change" (when-let [f (:on-change hs)] (f (ffi/event-text))) | ||
| 225 | + "activate" (when-let [f (:on-activate hs)] (f)) | ||
| 226 | + ;; Ctrl+V on a clipboard with no text on it. What is on it instead is | ||
| 227 | + ;; the caller's to find out — `clipboard-image-png!` is the only | ||
| 228 | + ;; question the backend answers about it. | ||
| 229 | + "paste-empty" (when-let [f (:on-paste-empty hs)] (f)) | ||
| 230 | + nil)) | ||
| 231 | + (recur)))) | ||
| 232 | + | ||
| 233 | +;; --- the event loop ---------------------------------------------------------- | ||
| 234 | +(defn- clear-children! | ||
| 235 | + "Drop everything under `node`. Removing a child frees it, so this walks the | ||
| 236 | + first slot until there is nothing left rather than iterating an index." | ||
| 237 | + [node] | ||
| 238 | + (loop [] | ||
| 239 | + (when (pos? (ffi/node-child-count node)) | ||
| 240 | + (ffi/node-remove! node (ffi/node-child-at node 0)) | ||
| 241 | + (recur))) | ||
| 242 | + (forget-dead-handlers!) | ||
| 243 | + nil) | ||
| 244 | + | ||
| 245 | +(defn window-width | ||
| 246 | + "The width in points of the window's content area, as the last painted frame | ||
| 247 | + measured it. 0 before the first frame. | ||
| 248 | + | ||
| 249 | + This is how a layout asks how much room it has: nothing else here reports a | ||
| 250 | + size, and the window is not resizable from this side either. Read it from a | ||
| 251 | + timer — `every!` — rather than per render, and hold it in a ratom, so that | ||
| 252 | + the components which switch on it re-render only when it actually changes." | ||
| 253 | + [] | ||
| 254 | + (ffi/node-get-num (ffi/tree-root) "window-width")) | ||
| 255 | + | ||
| 256 | +(defn clipboard-image-png! | ||
| 257 | + "Write the picture on the system clipboard to `path` as a PNG. True when | ||
| 258 | + there was one; false for an empty clipboard, text on it, an unwritable path, | ||
| 259 | + or a platform with no clipboard of images (Android). | ||
| 260 | + | ||
| 261 | + A paste of a picture reaches no handler of its own — the backend delivers | ||
| 262 | + clipboard text only — so a caller asks for it, from whatever gesture it means | ||
| 263 | + paste by. An `:entry`'s `:on-paste-empty` is that gesture where the reader | ||
| 264 | + expects it: a Ctrl+V the field had no text to answer with." | ||
| 265 | + [path] | ||
| 266 | + (ffi/clipboard-image-png! path)) | ||
| 267 | + | ||
| 268 | +(defn open-url! | ||
| 269 | + "Hand `url` to whatever shows web pages here — xdg-open or `open` on a | ||
| 270 | + desktop, an ACTION_VIEW intent on Android. True when something took it; | ||
| 271 | + false leaves the caller to show the URL and let the reader carry it across. | ||
| 272 | + | ||
| 273 | + A sign-in that goes through a browser is the reason this exists: the app | ||
| 274 | + leaves for a page and the page comes back to it." | ||
| 275 | + [url] | ||
| 276 | + (ffi/open-url! url)) | ||
| 277 | + | ||
| 278 | +(defn frame-rgba! | ||
| 279 | + "Hand the backend a frame of live pixels under `key`. An `:image` node with | ||
| 280 | + `:feed key` paints the latest one. | ||
| 281 | + | ||
| 282 | + `rgba` is a foreign pointer — `width * height * 4` un-premultiplied bytes, | ||
| 283 | + row-major — and is copied before this returns, so whoever owns it may reuse | ||
| 284 | + it immediately. False when the length does not match the dimensions. | ||
| 285 | + | ||
| 286 | + This is the source an `:image` `:src` cannot be: a `:src` decodes a file and | ||
| 287 | + caches it by path for the life of the process, which is right for a picture | ||
| 288 | + in a message and wrong for one that is new thirty times a second. Frames | ||
| 289 | + coalesce rather than queue, so a source faster than the window costs nothing. | ||
| 290 | + | ||
| 291 | + On the window's thread, like everything else here: a frame produced on a | ||
| 292 | + decoder thread is the caller's to hand across." | ||
| 293 | + [key width height rgba] | ||
| 294 | + (ffi/frame-rgba! key width height rgba)) | ||
| 295 | + | ||
| 296 | +(defn frame-drop! | ||
| 297 | + "Forget the feed named `key` and release its texture; true when there was | ||
| 298 | + one. A source that has stopped keeps painting its last frame otherwise." | ||
| 299 | + [key] | ||
| 300 | + (ffi/frame-drop! key)) | ||
| 301 | + | ||
| 302 | +(defn quit! | ||
| 303 | + "Stop the running loop and close the window." | ||
| 304 | + [] | ||
| 305 | + (reset! quit-requested true) | ||
| 306 | + nil) | ||
| 307 | + | ||
| 308 | +(defn- run! | ||
| 309 | + "glimmer.backend's :run. Opens a window, mounts the root component into the | ||
| 310 | + library's root node, and paints until the window closes or `quit!` is called. | ||
| 311 | + Blocks, like every UI main loop. | ||
| 312 | + | ||
| 313 | + Options (on top of glimmer's own): | ||
| 314 | + :title :width :height the window | ||
| 315 | + :fps frame rate cap (default 60) | ||
| 316 | + :mode :dark (default) or :light | ||
| 317 | + :font path to a TTF/OTF to use for UI text | ||
| 318 | + :auto-quit-ms stop after roughly this long — for smoke tests, | ||
| 319 | + which have nobody to close the window | ||
| 320 | + | ||
| 321 | + The window is closed in a finally, so a handler that throws does not leave a | ||
| 322 | + GPU surface and an event loop behind." | ||
| 323 | + [opts mount-root!] | ||
| 324 | + (let [{:keys [title width height fps mode font auto-quit-ms] | ||
| 325 | + :or {title "glimmer" width 900 height 640 fps 60}} opts] | ||
| 326 | + (when-not (ffi/open! width height title) | ||
| 327 | + (throw (ex-info "vidya could not open a window" | ||
| 328 | + {:title title :width width :height height}))) | ||
| 329 | + (reset! quit-requested false) | ||
| 330 | + (ffi/set-target-fps! fps) | ||
| 331 | + (ffi/set-mode! (if (= mode :light) ffi/light-mode ffi/dark-mode)) | ||
| 332 | + (when font (ffi/load-font! font)) | ||
| 333 | + (let [started (now-ms) | ||
| 334 | + root (ffi/tree-root)] | ||
| 335 | + (try | ||
| 336 | + ;; The library's root outlives a run — it is process-wide, not per | ||
| 337 | + ;; window — so a second `ui/run` in one session (a REPL, a test) would | ||
| 338 | + ;; otherwise mount its tree alongside the last one's. | ||
| 339 | + (clear-children! root) | ||
| 340 | + (mount-root! root :window) | ||
| 341 | + (reset! b/loop-running? true) | ||
| 342 | + (loop [] | ||
| 343 | + (drain!) | ||
| 344 | + (pump-timers!) | ||
| 345 | + ;; One call paints the whole tree; the events it produced are read | ||
| 346 | + ;; straight after, while the frame that caused them is still the | ||
| 347 | + ;; frame the components rendered. | ||
| 348 | + (ffi/tree-frame!) | ||
| 349 | + (dispatch-events!) | ||
| 350 | + (when-not (or @quit-requested | ||
| 351 | + (ffi/should-close?) | ||
| 352 | + (and auto-quit-ms (>= (- (now-ms) started) auto-quit-ms))) | ||
| 353 | + (recur))) | ||
| 354 | + (finally | ||
| 355 | + (reset! b/loop-running? false) | ||
| 356 | + (cancel-all!) | ||
| 357 | + (ffi/close!) | ||
| 358 | + (reset! handlers {})))))) | ||
| 359 | + | ||
| 360 | +;; --- looking at what was rendered -------------------------------------------- | ||
| 361 | +(defn dump-str | ||
| 362 | + "The rendered tree as hiccup text, read back out of the library. | ||
| 363 | + | ||
| 364 | + With no argument, the whole window; with a node handle, that subtree. This is | ||
| 365 | + the tree as it *is* after the reconciler has run, not what a component | ||
| 366 | + returned, so paste it into a bug report and the two can be compared. | ||
| 367 | + | ||
| 368 | + Two things to know when reading one. `:hbox` and `:vbox` are one node down | ||
| 369 | + there and both dump as `:box`, with the orientation in the props; and no | ||
| 370 | + `:on-*` appears, because handlers are held on this side and never sent." | ||
| 371 | + ([] (dump-str 0)) | ||
| 372 | + ([node] (ffi/tree-dump node))) | ||
| 373 | + | ||
| 374 | +(defn dump | ||
| 375 | + "`dump-str`, read back as hiccup data — vectors, keywords and maps — for a | ||
| 376 | + test that wants to assert on a subtree rather than on a string." | ||
| 377 | + ([] (dump 0)) | ||
| 378 | + ([node] (read-string (dump-str node)))) | ||
| 379 | + | ||
| 380 | +(defn dump! | ||
| 381 | + "Print `dump-str` to stdout. The one you want from a handler or the REPL." | ||
| 382 | + ([] (dump! 0)) | ||
| 383 | + ([node] (println (dump-str node)) nil)) | ||
| 384 | + | ||
| 385 | +;; --- the backend ------------------------------------------------------------- | ||
| 386 | +(def backend | ||
| 387 | + "The Vidya backend map handed to glimmer.backend/register!. See that | ||
| 388 | + namespace for the contract each key satisfies." | ||
| 389 | + {:name :vidya | ||
| 390 | + :create! create! | ||
| 391 | + :apply-props! apply-props! | ||
| 392 | + :append-child! append-child! | ||
| 393 | + :remove-child! remove-child! | ||
| 394 | + :replace-child! replace-child! | ||
| 395 | + :reorder-child! reorder-child! | ||
| 396 | + :schedule schedule | ||
| 397 | + :run run!}) | ||
| 398 | + | ||
| 399 | +(defn install! | ||
| 400 | + "Make Vidya/egui the backend glimmer renders with. Called on load, so | ||
| 401 | + requiring this namespace is enough; exposed for code that wants to be | ||
| 402 | + explicit, or to switch back after another backend was installed." | ||
| 403 | + [] | ||
| 404 | + (b/register! backend) | ||
| 405 | + nil) | ||
| 406 | + | ||
| 407 | +(defonce ^:private installed (do (install!) true)) | ||
added
jolt/glimmer-vidya/src/glimmer_vidya/ffi.jolt +121 -0 | new file mode 100644 | ||
| @@ -0,0 +1,121 @@ | ||
| 1 | +(ns glimmer-vidya.ffi | |
| 2 | + "Raw bindings for Vidya's retained-tree ABI (`ffi/include/vidya_tree.h`). | |
| 3 | + | |
| 4 | + Nothing here interprets a prop or an event; that is | |
| 5 | + `glimmer-vidya.core`'s job. This namespace exists so the boundary is one | |
| 6 | + readable list of symbols, and so the marshalling rules that come with it — | |
| 7 | + which pointers die when, which calls must stay on the window thread — are | |
| 8 | + stated once. | |
| 9 | + | |
| 10 | + **Strings returned by this library are borrowed.** `node-get-str`, | |
| 11 | + `event-name` and `event-text` all answer the same scratch buffer, which the | |
| 12 | + next such call overwrites. Jolt copies a `:string` return into a Scheme | |
| 13 | + string as it crosses, so holding the value is safe; holding the *pointer* is | |
| 14 | + not, and there is no way to do that from here anyway. | |
| 15 | + | |
| 16 | + **Every call belongs to the thread that opened the window.** The library | |
| 17 | + keeps its state in thread-local storage, so a call from anywhere else is | |
| 18 | + inert rather than unsound — but it is still a bug. `glimmer-vidya.core` | |
| 19 | + routes off-thread work through glimmer's `schedule`." | |
| 20 | + (:require [jolt.ffi :as ffi])) | |
| 21 | + | |
| 22 | +;; --- window and frame (shared with the push/pop ABI) ------------------------- | |
| 23 | +(ffi/defcfn raw-open "vidya_open" [:int :int :string] :int) | |
| 24 | +(ffi/defcfn close! "vidya_close" [] :void) | |
| 25 | +(ffi/defcfn raw-should-close "vidya_should_close" [] :int) | |
| 26 | +(ffi/defcfn set-target-fps! "vidya_set_target_fps" [:int] :void) | |
| 27 | +(ffi/defcfn set-mode! "vidya_set_mode" [:int] :void) | |
| 28 | +(ffi/defcfn raw-load-font "vidya_load_font" [:string :int] :int) | |
| 29 | + | |
| 30 | +;; --- the tree ---------------------------------------------------------------- | |
| 31 | +(ffi/defcfn tree-root "vidya_tree_root" [] :int) | |
| 32 | +(ffi/defcfn node-new "vidya_node_new" [:string] :int) | |
| 33 | +(ffi/defcfn node-free! "vidya_node_free" [:int] :void) | |
| 34 | +(ffi/defcfn raw-node-exists "vidya_node_exists" [:int] :int) | |
| 35 | + | |
| 36 | +(ffi/defcfn node-set-str! "vidya_node_set_str" [:int :string :string] :void) | |
| 37 | +(ffi/defcfn node-set-num! "vidya_node_set_num" [:int :string :double] :void) | |
| 38 | +(ffi/defcfn raw-node-set-bool "vidya_node_set_bool" [:int :string :int] :void) | |
| 39 | +(ffi/defcfn node-clear-props! "vidya_node_clear_props" [:int] :void) | |
| 40 | + | |
| 41 | +(ffi/defcfn node-get-str "vidya_node_get_str" [:int :string] :string) | |
| 42 | +(ffi/defcfn node-get-num "vidya_node_get_num" [:int :string] :double) | |
| 43 | +(ffi/defcfn raw-node-get-bool "vidya_node_get_bool" [:int :string] :int) | |
| 44 | + | |
| 45 | +(ffi/defcfn node-tag "vidya_node_tag" [:int] :string) | |
| 46 | +(ffi/defcfn node-child-count "vidya_node_child_count" [:int] :int) | |
| 47 | +(ffi/defcfn node-child-at "vidya_node_child_at" [:int :int] :int) | |
| 48 | +(ffi/defcfn tree-dump "vidya_tree_dump" [:int] :string) | |
| 49 | + | |
| 50 | +(ffi/defcfn raw-node-append "vidya_node_append" [:int :int] :int) | |
| 51 | +(ffi/defcfn node-remove! "vidya_node_remove" [:int :int] :void) | |
| 52 | +(ffi/defcfn raw-node-insert-after "vidya_node_insert_after" [:int :int :int] :int) | |
| 53 | +(ffi/defcfn raw-node-replace "vidya_node_replace" [:int :int :int] :int) | |
| 54 | + | |
| 55 | +;; --- frame and events -------------------------------------------------------- | |
| 56 | +(ffi/defcfn tree-frame! "vidya_tree_frame" [] :void) | |
| 57 | +(ffi/defcfn raw-poll-event "vidya_tree_poll_event" [] :int) | |
| 58 | +(ffi/defcfn event-node "vidya_tree_event_node" [] :int) | |
| 59 | +(ffi/defcfn event-name "vidya_tree_event_name" [] :string) | |
| 60 | +(ffi/defcfn event-text "vidya_tree_event_text" [] :string) | |
| 61 | +(ffi/defcfn event-num "vidya_tree_event_num" [] :double) | |
| 62 | + | |
| 63 | +;; --- live frames ------------------------------------------------------------- | |
| 64 | +;; Pixels pushed in under a name, painted by an `:image` whose `:feed` names it. | |
| 65 | +;; `rgba` is a foreign pointer to width*height*4 bytes, copied before the call | |
| 66 | +;; returns — the backend keeps nothing, so the caller may reuse its buffer. The | |
| 67 | +;; pointer form is the point: a video frame moved through jolt as a byte array | |
| 68 | +;; would be copied twice a frame for nothing. | |
| 69 | +(ffi/defcfn raw-frame-rgba "vidya_frame_rgba" [:string :int :int :pointer] :int) | |
| 70 | +(ffi/defcfn raw-frame-drop "vidya_frame_drop" [:string] :int) | |
| 71 | + | |
| 72 | +;; --- the clipboard ----------------------------------------------------------- | |
| 73 | +;; Not an event: egui carries clipboard text into a frame and nothing else, so a | |
| 74 | +;; pasted picture is asked for rather than waited for. | |
| 75 | +(ffi/defcfn raw-clipboard-image-png "vidya_clipboard_image_png" [:string] :int) | |
| 76 | + | |
| 77 | +;; --- the browser ------------------------------------------------------------- | |
| 78 | +;; Leaving for a page and coming back is the platform's business: xdg-open on a | |
| 79 | +;; desktop, an ACTION_VIEW intent on Android. | |
| 80 | +(ffi/defcfn raw-open-url "vidya_open_url" [:string] :int) | |
| 81 | + | |
| 82 | +;; --- the int/bool seam ------------------------------------------------------- | |
| 83 | +;; C has no booleans; every predicate here crosses as 0 or 1. Converting at the | |
| 84 | +;; binding rather than at each call site keeps the rest of the backend written | |
| 85 | +;; in jolt's own truthiness. | |
| 86 | + | |
| 87 | +(def dark-mode 0) | |
| 88 | +(def light-mode 1) | |
| 89 | + | |
| 90 | +(defn open! | |
| 91 | + [width height title] | |
| 92 | + (not (zero? (raw-open width height title)))) | |
| 93 | + | |
| 94 | +(defn should-close? [] (not (zero? (raw-should-close)))) | |
| 95 | +(defn load-font! [path] (not (zero? (raw-load-font path 32)))) | |
| 96 | +(defn node-exists? [node] (not (zero? (raw-node-exists node)))) | |
| 97 | +(defn node-set-bool! [node key value] (raw-node-set-bool node key (if value 1 0))) | |
| 98 | +(defn node-get-bool [node key] (not (zero? (raw-node-get-bool node key)))) | |
| 99 | +(defn node-append! [parent child] (not (zero? (raw-node-append parent child)))) | |
| 100 | +(defn clipboard-image-png! | |
| 101 | + "Write the clipboard's picture to `path` as a PNG; true when there was one." | |
| 102 | + [path] | |
| 103 | + (not (zero? (raw-clipboard-image-png path)))) | |
| 104 | +(defn open-url! | |
| 105 | + "Hand `url` to the platform's browser; true when something took it." | |
| 106 | + [url] | |
| 107 | + (not (zero? (raw-open-url url)))) | |
| 108 | +(defn frame-rgba! | |
| 109 | + "Paint `rgba` — a pointer to width*height*4 un-premultiplied bytes — under | |
| 110 | + `key`. True when the frame was accepted; false for dimensions the length does | |
| 111 | + not match." | |
| 112 | + [key width height rgba] | |
| 113 | + (not (zero? (raw-frame-rgba key width height rgba)))) | |
| 114 | +(defn frame-drop! | |
| 115 | + "Forget the feed named `key`; true when there was one." | |
| 116 | + [key] | |
| 117 | + (not (zero? (raw-frame-drop key)))) | |
| 118 | +(defn node-insert-after! [parent child sibling] | |
| 119 | + (not (zero? (raw-node-insert-after parent child sibling)))) | |
| 120 | +(defn node-replace! [parent old new] (not (zero? (raw-node-replace parent old new)))) | |
| 121 | +(defn poll-event! [] (not (zero? (raw-poll-event)))) | |
| new file mode 100644 | |||
| @@ -0,0 +1,121 @@ | |||
| 1 | +(ns glimmer-vidya.ffi | ||
| 2 | + "Raw bindings for Vidya's retained-tree ABI (`ffi/include/vidya_tree.h`). | ||
| 3 | + | ||
| 4 | + Nothing here interprets a prop or an event; that is | ||
| 5 | + `glimmer-vidya.core`'s job. This namespace exists so the boundary is one | ||
| 6 | + readable list of symbols, and so the marshalling rules that come with it — | ||
| 7 | + which pointers die when, which calls must stay on the window thread — are | ||
| 8 | + stated once. | ||
| 9 | + | ||
| 10 | + **Strings returned by this library are borrowed.** `node-get-str`, | ||
| 11 | + `event-name` and `event-text` all answer the same scratch buffer, which the | ||
| 12 | + next such call overwrites. Jolt copies a `:string` return into a Scheme | ||
| 13 | + string as it crosses, so holding the value is safe; holding the *pointer* is | ||
| 14 | + not, and there is no way to do that from here anyway. | ||
| 15 | + | ||
| 16 | + **Every call belongs to the thread that opened the window.** The library | ||
| 17 | + keeps its state in thread-local storage, so a call from anywhere else is | ||
| 18 | + inert rather than unsound — but it is still a bug. `glimmer-vidya.core` | ||
| 19 | + routes off-thread work through glimmer's `schedule`." | ||
| 20 | + (:require [jolt.ffi :as ffi])) | ||
| 21 | + | ||
| 22 | +;; --- window and frame (shared with the push/pop ABI) ------------------------- | ||
| 23 | +(ffi/defcfn raw-open "vidya_open" [:int :int :string] :int) | ||
| 24 | +(ffi/defcfn close! "vidya_close" [] :void) | ||
| 25 | +(ffi/defcfn raw-should-close "vidya_should_close" [] :int) | ||
| 26 | +(ffi/defcfn set-target-fps! "vidya_set_target_fps" [:int] :void) | ||
| 27 | +(ffi/defcfn set-mode! "vidya_set_mode" [:int] :void) | ||
| 28 | +(ffi/defcfn raw-load-font "vidya_load_font" [:string :int] :int) | ||
| 29 | + | ||
| 30 | +;; --- the tree ---------------------------------------------------------------- | ||
| 31 | +(ffi/defcfn tree-root "vidya_tree_root" [] :int) | ||
| 32 | +(ffi/defcfn node-new "vidya_node_new" [:string] :int) | ||
| 33 | +(ffi/defcfn node-free! "vidya_node_free" [:int] :void) | ||
| 34 | +(ffi/defcfn raw-node-exists "vidya_node_exists" [:int] :int) | ||
| 35 | + | ||
| 36 | +(ffi/defcfn node-set-str! "vidya_node_set_str" [:int :string :string] :void) | ||
| 37 | +(ffi/defcfn node-set-num! "vidya_node_set_num" [:int :string :double] :void) | ||
| 38 | +(ffi/defcfn raw-node-set-bool "vidya_node_set_bool" [:int :string :int] :void) | ||
| 39 | +(ffi/defcfn node-clear-props! "vidya_node_clear_props" [:int] :void) | ||
| 40 | + | ||
| 41 | +(ffi/defcfn node-get-str "vidya_node_get_str" [:int :string] :string) | ||
| 42 | +(ffi/defcfn node-get-num "vidya_node_get_num" [:int :string] :double) | ||
| 43 | +(ffi/defcfn raw-node-get-bool "vidya_node_get_bool" [:int :string] :int) | ||
| 44 | + | ||
| 45 | +(ffi/defcfn node-tag "vidya_node_tag" [:int] :string) | ||
| 46 | +(ffi/defcfn node-child-count "vidya_node_child_count" [:int] :int) | ||
| 47 | +(ffi/defcfn node-child-at "vidya_node_child_at" [:int :int] :int) | ||
| 48 | +(ffi/defcfn tree-dump "vidya_tree_dump" [:int] :string) | ||
| 49 | + | ||
| 50 | +(ffi/defcfn raw-node-append "vidya_node_append" [:int :int] :int) | ||
| 51 | +(ffi/defcfn node-remove! "vidya_node_remove" [:int :int] :void) | ||
| 52 | +(ffi/defcfn raw-node-insert-after "vidya_node_insert_after" [:int :int :int] :int) | ||
| 53 | +(ffi/defcfn raw-node-replace "vidya_node_replace" [:int :int :int] :int) | ||
| 54 | + | ||
| 55 | +;; --- frame and events -------------------------------------------------------- | ||
| 56 | +(ffi/defcfn tree-frame! "vidya_tree_frame" [] :void) | ||
| 57 | +(ffi/defcfn raw-poll-event "vidya_tree_poll_event" [] :int) | ||
| 58 | +(ffi/defcfn event-node "vidya_tree_event_node" [] :int) | ||
| 59 | +(ffi/defcfn event-name "vidya_tree_event_name" [] :string) | ||
| 60 | +(ffi/defcfn event-text "vidya_tree_event_text" [] :string) | ||
| 61 | +(ffi/defcfn event-num "vidya_tree_event_num" [] :double) | ||
| 62 | + | ||
| 63 | +;; --- live frames ------------------------------------------------------------- | ||
| 64 | +;; Pixels pushed in under a name, painted by an `:image` whose `:feed` names it. | ||
| 65 | +;; `rgba` is a foreign pointer to width*height*4 bytes, copied before the call | ||
| 66 | +;; returns — the backend keeps nothing, so the caller may reuse its buffer. The | ||
| 67 | +;; pointer form is the point: a video frame moved through jolt as a byte array | ||
| 68 | +;; would be copied twice a frame for nothing. | ||
| 69 | +(ffi/defcfn raw-frame-rgba "vidya_frame_rgba" [:string :int :int :pointer] :int) | ||
| 70 | +(ffi/defcfn raw-frame-drop "vidya_frame_drop" [:string] :int) | ||
| 71 | + | ||
| 72 | +;; --- the clipboard ----------------------------------------------------------- | ||
| 73 | +;; Not an event: egui carries clipboard text into a frame and nothing else, so a | ||
| 74 | +;; pasted picture is asked for rather than waited for. | ||
| 75 | +(ffi/defcfn raw-clipboard-image-png "vidya_clipboard_image_png" [:string] :int) | ||
| 76 | + | ||
| 77 | +;; --- the browser ------------------------------------------------------------- | ||
| 78 | +;; Leaving for a page and coming back is the platform's business: xdg-open on a | ||
| 79 | +;; desktop, an ACTION_VIEW intent on Android. | ||
| 80 | +(ffi/defcfn raw-open-url "vidya_open_url" [:string] :int) | ||
| 81 | + | ||
| 82 | +;; --- the int/bool seam ------------------------------------------------------- | ||
| 83 | +;; C has no booleans; every predicate here crosses as 0 or 1. Converting at the | ||
| 84 | +;; binding rather than at each call site keeps the rest of the backend written | ||
| 85 | +;; in jolt's own truthiness. | ||
| 86 | + | ||
| 87 | +(def dark-mode 0) | ||
| 88 | +(def light-mode 1) | ||
| 89 | + | ||
| 90 | +(defn open! | ||
| 91 | + [width height title] | ||
| 92 | + (not (zero? (raw-open width height title)))) | ||
| 93 | + | ||
| 94 | +(defn should-close? [] (not (zero? (raw-should-close)))) | ||
| 95 | +(defn load-font! [path] (not (zero? (raw-load-font path 32)))) | ||
| 96 | +(defn node-exists? [node] (not (zero? (raw-node-exists node)))) | ||
| 97 | +(defn node-set-bool! [node key value] (raw-node-set-bool node key (if value 1 0))) | ||
| 98 | +(defn node-get-bool [node key] (not (zero? (raw-node-get-bool node key)))) | ||
| 99 | +(defn node-append! [parent child] (not (zero? (raw-node-append parent child)))) | ||
| 100 | +(defn clipboard-image-png! | ||
| 101 | + "Write the clipboard's picture to `path` as a PNG; true when there was one." | ||
| 102 | + [path] | ||
| 103 | + (not (zero? (raw-clipboard-image-png path)))) | ||
| 104 | +(defn open-url! | ||
| 105 | + "Hand `url` to the platform's browser; true when something took it." | ||
| 106 | + [url] | ||
| 107 | + (not (zero? (raw-open-url url)))) | ||
| 108 | +(defn frame-rgba! | ||
| 109 | + "Paint `rgba` — a pointer to width*height*4 un-premultiplied bytes — under | ||
| 110 | + `key`. True when the frame was accepted; false for dimensions the length does | ||
| 111 | + not match." | ||
| 112 | + [key width height rgba] | ||
| 113 | + (not (zero? (raw-frame-rgba key width height rgba)))) | ||
| 114 | +(defn frame-drop! | ||
| 115 | + "Forget the feed named `key`; true when there was one." | ||
| 116 | + [key] | ||
| 117 | + (not (zero? (raw-frame-drop key)))) | ||
| 118 | +(defn node-insert-after! [parent child sibling] | ||
| 119 | + (not (zero? (raw-node-insert-after parent child sibling)))) | ||
| 120 | +(defn node-replace! [parent old new] (not (zero? (raw-node-replace parent old new)))) | ||
| 121 | +(defn poll-event! [] (not (zero? (raw-poll-event)))) | ||
added
jolt/glimmer-vidya/test/glimmer_vidya/backend_test.jolt +197 -0 | new file mode 100644 | ||
| @@ -0,0 +1,197 @@ | ||
| 1 | +(ns glimmer-vidya.backend-test | |
| 2 | + "The backend against a real node tree, with no window open. | |
| 3 | + | |
| 4 | + Node calls do not need a GL surface — only painting does — so the whole | |
| 5 | + reconcile path can be asserted here: what glimmer creates, what it patches, | |
| 6 | + what it reuses across a reactive change, and what it moves rather than | |
| 7 | + rebuilds. The tree is read back through the same ABI the backend writes it | |
| 8 | + with, so these tests fail if either side of the boundary drifts." | |
| 9 | + (:require [clojure.test :refer [deftest is testing]] | |
| 10 | + [glimmer.core :as ui] | |
| 11 | + [glimmer.ratom :as r] | |
| 12 | + [glimmer-vidya.core :as backend] | |
| 13 | + [glimmer-vidya.ffi :as ffi])) | |
| 14 | + | |
| 15 | +;; --- reading the tree back --------------------------------------------------- | |
| 16 | +(defn- children [node] | |
| 17 | + (mapv #(ffi/node-child-at node %) (range (ffi/node-child-count node)))) | |
| 18 | + | |
| 19 | +(defn- shape | |
| 20 | + "A node as plain data: tag, the props asked for, and its children." | |
| 21 | + ([node] (shape node [])) | |
| 22 | + ([node keys] | |
| 23 | + {:tag (ffi/node-tag node) | |
| 24 | + ;; An unset prop reads as the empty string, so it is left out rather than | |
| 25 | + ;; recorded as one — the shape says what a node has, not what it lacks. | |
| 26 | + :props (reduce (fn [acc k] | |
| 27 | + (let [v (ffi/node-get-str node (name k))] | |
| 28 | + (if (seq v) (assoc acc k v) acc))) | |
| 29 | + {} | |
| 30 | + keys) | |
| 31 | + :children (mapv #(shape % keys) (children node))})) | |
| 32 | + | |
| 33 | +(defn- tags [node] (mapv ffi/node-tag (children node))) | |
| 34 | +(defn- labels [node] (mapv #(ffi/node-get-str % "label") (children node))) | |
| 35 | + | |
| 36 | +(defn- fresh-root | |
| 37 | + "A detached node to mount into, so tests do not share the window root." | |
| 38 | + [] | |
| 39 | + (ffi/node-new "window")) | |
| 40 | + | |
| 41 | +;; --- the backend operations -------------------------------------------------- | |
| 42 | +(deftest create-applies-props-in-their-own-types | |
| 43 | + (let [{:keys [create! apply-props!]} backend/backend | |
| 44 | + node (create! :button {:label "go" :kind :primary :sensitive false})] | |
| 45 | + (is (= "button" (ffi/node-tag node))) | |
| 46 | + (is (= "go" (ffi/node-get-str node "label"))) | |
| 47 | + (testing "a keyword prop crosses as its name" | |
| 48 | + (is (= "primary" (ffi/node-get-str node "kind")))) | |
| 49 | + (testing "a boolean crosses as a boolean, not as text" | |
| 50 | + (is (false? (ffi/node-get-bool node "sensitive")))) | |
| 51 | + | |
| 52 | + (apply-props! :button node {:label "stop"}) | |
| 53 | + (is (= "stop" (ffi/node-get-str node "label"))) | |
| 54 | + (testing "a prop the re-render stopped setting is gone, not stale" | |
| 55 | + ;; A prop that is not set reads as its zero value, so `sensitive` is | |
| 56 | + ;; false here whether it was cleared or never written. What makes a | |
| 57 | + ;; cleared node sensitive again is the painter's default, not this read. | |
| 58 | + (is (= "" (ffi/node-get-str node "kind"))) | |
| 59 | + (is (= 0.0 (ffi/node-get-num node "value")))))) | |
| 60 | + | |
| 61 | +(deftest numbers-and-handlers-are-kept-apart | |
| 62 | + (let [{:keys [create!]} backend/backend | |
| 63 | + node (create! :progress {:value 0.25 :on-click (fn [] :ignored)})] | |
| 64 | + (is (= 0.25 (ffi/node-get-num node "value"))) | |
| 65 | + (testing "a handler is held on this side; it is not a prop" | |
| 66 | + (is (= "" (ffi/node-get-str node "on-click")))))) | |
| 67 | + | |
| 68 | +(deftest hbox-and-vbox-carry-their-orientation | |
| 69 | + (let [{:keys [create!]} backend/backend] | |
| 70 | + (is (= "horizontal" (ffi/node-get-str (create! :hbox {}) "orientation"))) | |
| 71 | + (is (= "vertical" (ffi/node-get-str (create! :vbox {}) "orientation"))) | |
| 72 | + (testing "both are one node kind; the tag only implies the prop" | |
| 73 | + (is (= "box" (ffi/node-tag (create! :hbox {}))))) | |
| 74 | + (testing "an explicit orientation wins" | |
| 75 | + (is (= "vertical" | |
| 76 | + (ffi/node-get-str (create! :hbox {:orientation :vertical}) "orientation")))))) | |
| 77 | + | |
| 78 | +(deftest children-are-appended-removed-replaced-and-reordered | |
| 79 | + (let [{:keys [create! append-child! remove-child! replace-child! reorder-child!]} | |
| 80 | + backend/backend | |
| 81 | + box (create! :vbox {}) | |
| 82 | + a (create! :label {:label "a"}) | |
| 83 | + b (create! :label {:label "b"}) | |
| 84 | + c (create! :label {:label "c"})] | |
| 85 | + (append-child! :box box a) | |
| 86 | + (append-child! :box box b) | |
| 87 | + (is (= ["a" "b"] (labels box))) | |
| 88 | + | |
| 89 | + (replace-child! :box box b c) | |
| 90 | + (is (= ["a" "c"] (labels box))) | |
| 91 | + (testing "the replaced widget is freed, not merely unparented" | |
| 92 | + (is (false? (ffi/node-exists? b)))) | |
| 93 | + | |
| 94 | + (let [d (create! :label {:label "d"})] | |
| 95 | + (append-child! :box box d) | |
| 96 | + (reorder-child! :box box d nil) ; nil sibling = move to the front | |
| 97 | + (is (= ["d" "a" "c"] (labels box))) | |
| 98 | + (reorder-child! :box box d a) ; after a | |
| 99 | + (is (= ["a" "d" "c"] (labels box))) | |
| 100 | + (remove-child! :box box d) | |
| 101 | + (is (= ["a" "c"] (labels box)))))) | |
| 102 | + | |
| 103 | +(deftest removing-a-container-frees-what-was-inside-it | |
| 104 | + (let [{:keys [create! append-child! remove-child!]} backend/backend | |
| 105 | + root (fresh-root) | |
| 106 | + box (create! :vbox {}) | |
| 107 | + leaf (create! :label {:label "gone"})] | |
| 108 | + (append-child! :window root box) | |
| 109 | + (append-child! :box box leaf) | |
| 110 | + (remove-child! :window root box) | |
| 111 | + (is (false? (ffi/node-exists? box))) | |
| 112 | + (is (false? (ffi/node-exists? leaf))))) | |
| 113 | + | |
| 114 | +;; --- the reconciler on top of it --------------------------------------------- | |
| 115 | +(defn- greeting [who] | |
| 116 | + [:vbox {:spacing 6} | |
| 117 | + [:title {:label "hello"}] | |
| 118 | + [:label {:label who}]]) | |
| 119 | + | |
| 120 | +(deftest a_component_mounts_as_native_nodes | |
| 121 | + (let [root (fresh-root)] | |
| 122 | + (ui/mount root :window [greeting "world"]) | |
| 123 | + (is (= {:tag "window" | |
| 124 | + :props {} | |
| 125 | + :children [{:tag "box" :props {} | |
| 126 | + :children [{:tag "title" :props {:label "hello"} :children []} | |
| 127 | + {:tag "label" :props {:label "world"} :children []}]}]} | |
| 128 | + (shape root [:label]))))) | |
| 129 | + | |
| 130 | +(deftest a_reactive_change_patches_the_same_node | |
| 131 | + (let [root (fresh-root) | |
| 132 | + who (r/atom "world")] | |
| 133 | + (ui/mount root :window [(fn [] [:label {:label @who}])]) | |
| 134 | + (let [node (first (children root))] | |
| 135 | + (is (= "world" (ffi/node-get-str node "label"))) | |
| 136 | + (reset! who "glimmer") | |
| 137 | + (testing "the widget is patched in place rather than rebuilt" | |
| 138 | + (is (= [node] (children root))) | |
| 139 | + (is (= "glimmer" (ffi/node-get-str node "label"))))))) | |
| 140 | + | |
| 141 | +(deftest a_changed_tag_replaces_the_widget | |
| 142 | + (let [root (fresh-root) | |
| 143 | + loading? (r/atom true)] | |
| 144 | + (ui/mount root :window [(fn [] (if @loading? | |
| 145 | + [:spinner {:label "…"}] | |
| 146 | + [:label {:label "done"}]))]) | |
| 147 | + (let [before (first (children root))] | |
| 148 | + (is (= "spinner" (ffi/node-tag before))) | |
| 149 | + (reset! loading? false) | |
| 150 | + (is (= ["label"] (tags root))) | |
| 151 | + (is (false? (ffi/node-exists? before)))))) | |
| 152 | + | |
| 153 | +(deftest a_keyed_list_reuses_widgets_across_a_reorder | |
| 154 | + (let [root (fresh-root) | |
| 155 | + items (r/atom [:a :b :c])] | |
| 156 | + (ui/mount root :window | |
| 157 | + [(fn [] | |
| 158 | + (into [:vbox {}] | |
| 159 | + (for [item @items] | |
| 160 | + ^{:key item} [:label {:label (name item)}])))]) | |
| 161 | + (let [box (first (children root)) | |
| 162 | + before (children box)] | |
| 163 | + (is (= ["a" "b" "c"] (labels box))) | |
| 164 | + (reset! items [:c :a :b]) | |
| 165 | + (testing "the same three nodes, in a new order" | |
| 166 | + (is (= ["c" "a" "b"] (labels box))) | |
| 167 | + (is (= (set before) (set (children box))))) | |
| 168 | + (testing "and a dropped key takes its widget with it" | |
| 169 | + (reset! items [:c :b]) | |
| 170 | + (is (= ["c" "b"] (labels box))) | |
| 171 | + (is (= 2 (ffi/node-child-count box))))))) | |
| 172 | + | |
| 173 | +;; --- the hiccup dump --------------------------------------------------------- | |
| 174 | +(deftest a_dump_reads_back_as_the_hiccup_that_was_mounted | |
| 175 | + (let [root (fresh-root)] | |
| 176 | + (ui/mount root :window [greeting "world"]) | |
| 177 | + (testing "the tree comes back as data, one node per hiccup vector" | |
| 178 | + ;; :vbox is a box with an orientation down there, and :spacing crossed as | |
| 179 | + ;; a double — so this is the mounted hiccup, not a copy of its source. | |
| 180 | + (is (= [:window {} | |
| 181 | + [:box {:orientation "vertical" :spacing 6} | |
| 182 | + [:title {:label "hello"}] | |
| 183 | + [:label {:label "world"}]]] | |
| 184 | + (backend/dump root)))) | |
| 185 | + (testing "and as text, for a bug report" | |
| 186 | + (is (= "[:window {}\n [:box {:orientation \"vertical\" :spacing 6}\n [:title {:label \"hello\"}]\n [:label {:label \"world\"}]]]" | |
| 187 | + (backend/dump-str root)))))) | |
| 188 | + | |
| 189 | +(deftest a_dump_shows_a_reactive_change_and_hides_handlers | |
| 190 | + (let [root (fresh-root) | |
| 191 | + who (r/atom "world")] | |
| 192 | + (ui/mount root :window [(fn [] [:button {:label @who :on-click (fn [] nil)}])]) | |
| 193 | + (is (= [:window {} [:button {:label "world"}]] (backend/dump root))) | |
| 194 | + (testing "the handler is not in it — it never crossed the boundary" | |
| 195 | + (is (not (contains? (second (nth (backend/dump root) 2)) :on-click)))) | |
| 196 | + (reset! who "glimmer") | |
| 197 | + (is (= [:window {} [:button {:label "glimmer"}]] (backend/dump root))))) | |
| new file mode 100644 | |||
| @@ -0,0 +1,197 @@ | |||
| 1 | +(ns glimmer-vidya.backend-test | ||
| 2 | + "The backend against a real node tree, with no window open. | ||
| 3 | + | ||
| 4 | + Node calls do not need a GL surface — only painting does — so the whole | ||
| 5 | + reconcile path can be asserted here: what glimmer creates, what it patches, | ||
| 6 | + what it reuses across a reactive change, and what it moves rather than | ||
| 7 | + rebuilds. The tree is read back through the same ABI the backend writes it | ||
| 8 | + with, so these tests fail if either side of the boundary drifts." | ||
| 9 | + (:require [clojure.test :refer [deftest is testing]] | ||
| 10 | + [glimmer.core :as ui] | ||
| 11 | + [glimmer.ratom :as r] | ||
| 12 | + [glimmer-vidya.core :as backend] | ||
| 13 | + [glimmer-vidya.ffi :as ffi])) | ||
| 14 | + | ||
| 15 | +;; --- reading the tree back --------------------------------------------------- | ||
| 16 | +(defn- children [node] | ||
| 17 | + (mapv #(ffi/node-child-at node %) (range (ffi/node-child-count node)))) | ||
| 18 | + | ||
| 19 | +(defn- shape | ||
| 20 | + "A node as plain data: tag, the props asked for, and its children." | ||
| 21 | + ([node] (shape node [])) | ||
| 22 | + ([node keys] | ||
| 23 | + {:tag (ffi/node-tag node) | ||
| 24 | + ;; An unset prop reads as the empty string, so it is left out rather than | ||
| 25 | + ;; recorded as one — the shape says what a node has, not what it lacks. | ||
| 26 | + :props (reduce (fn [acc k] | ||
| 27 | + (let [v (ffi/node-get-str node (name k))] | ||
| 28 | + (if (seq v) (assoc acc k v) acc))) | ||
| 29 | + {} | ||
| 30 | + keys) | ||
| 31 | + :children (mapv #(shape % keys) (children node))})) | ||
| 32 | + | ||
| 33 | +(defn- tags [node] (mapv ffi/node-tag (children node))) | ||
| 34 | +(defn- labels [node] (mapv #(ffi/node-get-str % "label") (children node))) | ||
| 35 | + | ||
| 36 | +(defn- fresh-root | ||
| 37 | + "A detached node to mount into, so tests do not share the window root." | ||
| 38 | + [] | ||
| 39 | + (ffi/node-new "window")) | ||
| 40 | + | ||
| 41 | +;; --- the backend operations -------------------------------------------------- | ||
| 42 | +(deftest create-applies-props-in-their-own-types | ||
| 43 | + (let [{:keys [create! apply-props!]} backend/backend | ||
| 44 | + node (create! :button {:label "go" :kind :primary :sensitive false})] | ||
| 45 | + (is (= "button" (ffi/node-tag node))) | ||
| 46 | + (is (= "go" (ffi/node-get-str node "label"))) | ||
| 47 | + (testing "a keyword prop crosses as its name" | ||
| 48 | + (is (= "primary" (ffi/node-get-str node "kind")))) | ||
| 49 | + (testing "a boolean crosses as a boolean, not as text" | ||
| 50 | + (is (false? (ffi/node-get-bool node "sensitive")))) | ||
| 51 | + | ||
| 52 | + (apply-props! :button node {:label "stop"}) | ||
| 53 | + (is (= "stop" (ffi/node-get-str node "label"))) | ||
| 54 | + (testing "a prop the re-render stopped setting is gone, not stale" | ||
| 55 | + ;; A prop that is not set reads as its zero value, so `sensitive` is | ||
| 56 | + ;; false here whether it was cleared or never written. What makes a | ||
| 57 | + ;; cleared node sensitive again is the painter's default, not this read. | ||
| 58 | + (is (= "" (ffi/node-get-str node "kind"))) | ||
| 59 | + (is (= 0.0 (ffi/node-get-num node "value")))))) | ||
| 60 | + | ||
| 61 | +(deftest numbers-and-handlers-are-kept-apart | ||
| 62 | + (let [{:keys [create!]} backend/backend | ||
| 63 | + node (create! :progress {:value 0.25 :on-click (fn [] :ignored)})] | ||
| 64 | + (is (= 0.25 (ffi/node-get-num node "value"))) | ||
| 65 | + (testing "a handler is held on this side; it is not a prop" | ||
| 66 | + (is (= "" (ffi/node-get-str node "on-click")))))) | ||
| 67 | + | ||
| 68 | +(deftest hbox-and-vbox-carry-their-orientation | ||
| 69 | + (let [{:keys [create!]} backend/backend] | ||
| 70 | + (is (= "horizontal" (ffi/node-get-str (create! :hbox {}) "orientation"))) | ||
| 71 | + (is (= "vertical" (ffi/node-get-str (create! :vbox {}) "orientation"))) | ||
| 72 | + (testing "both are one node kind; the tag only implies the prop" | ||
| 73 | + (is (= "box" (ffi/node-tag (create! :hbox {}))))) | ||
| 74 | + (testing "an explicit orientation wins" | ||
| 75 | + (is (= "vertical" | ||
| 76 | + (ffi/node-get-str (create! :hbox {:orientation :vertical}) "orientation")))))) | ||
| 77 | + | ||
| 78 | +(deftest children-are-appended-removed-replaced-and-reordered | ||
| 79 | + (let [{:keys [create! append-child! remove-child! replace-child! reorder-child!]} | ||
| 80 | + backend/backend | ||
| 81 | + box (create! :vbox {}) | ||
| 82 | + a (create! :label {:label "a"}) | ||
| 83 | + b (create! :label {:label "b"}) | ||
| 84 | + c (create! :label {:label "c"})] | ||
| 85 | + (append-child! :box box a) | ||
| 86 | + (append-child! :box box b) | ||
| 87 | + (is (= ["a" "b"] (labels box))) | ||
| 88 | + | ||
| 89 | + (replace-child! :box box b c) | ||
| 90 | + (is (= ["a" "c"] (labels box))) | ||
| 91 | + (testing "the replaced widget is freed, not merely unparented" | ||
| 92 | + (is (false? (ffi/node-exists? b)))) | ||
| 93 | + | ||
| 94 | + (let [d (create! :label {:label "d"})] | ||
| 95 | + (append-child! :box box d) | ||
| 96 | + (reorder-child! :box box d nil) ; nil sibling = move to the front | ||
| 97 | + (is (= ["d" "a" "c"] (labels box))) | ||
| 98 | + (reorder-child! :box box d a) ; after a | ||
| 99 | + (is (= ["a" "d" "c"] (labels box))) | ||
| 100 | + (remove-child! :box box d) | ||
| 101 | + (is (= ["a" "c"] (labels box)))))) | ||
| 102 | + | ||
| 103 | +(deftest removing-a-container-frees-what-was-inside-it | ||
| 104 | + (let [{:keys [create! append-child! remove-child!]} backend/backend | ||
| 105 | + root (fresh-root) | ||
| 106 | + box (create! :vbox {}) | ||
| 107 | + leaf (create! :label {:label "gone"})] | ||
| 108 | + (append-child! :window root box) | ||
| 109 | + (append-child! :box box leaf) | ||
| 110 | + (remove-child! :window root box) | ||
| 111 | + (is (false? (ffi/node-exists? box))) | ||
| 112 | + (is (false? (ffi/node-exists? leaf))))) | ||
| 113 | + | ||
| 114 | +;; --- the reconciler on top of it --------------------------------------------- | ||
| 115 | +(defn- greeting [who] | ||
| 116 | + [:vbox {:spacing 6} | ||
| 117 | + [:title {:label "hello"}] | ||
| 118 | + [:label {:label who}]]) | ||
| 119 | + | ||
| 120 | +(deftest a_component_mounts_as_native_nodes | ||
| 121 | + (let [root (fresh-root)] | ||
| 122 | + (ui/mount root :window [greeting "world"]) | ||
| 123 | + (is (= {:tag "window" | ||
| 124 | + :props {} | ||
| 125 | + :children [{:tag "box" :props {} | ||
| 126 | + :children [{:tag "title" :props {:label "hello"} :children []} | ||
| 127 | + {:tag "label" :props {:label "world"} :children []}]}]} | ||
| 128 | + (shape root [:label]))))) | ||
| 129 | + | ||
| 130 | +(deftest a_reactive_change_patches_the_same_node | ||
| 131 | + (let [root (fresh-root) | ||
| 132 | + who (r/atom "world")] | ||
| 133 | + (ui/mount root :window [(fn [] [:label {:label @who}])]) | ||
| 134 | + (let [node (first (children root))] | ||
| 135 | + (is (= "world" (ffi/node-get-str node "label"))) | ||
| 136 | + (reset! who "glimmer") | ||
| 137 | + (testing "the widget is patched in place rather than rebuilt" | ||
| 138 | + (is (= [node] (children root))) | ||
| 139 | + (is (= "glimmer" (ffi/node-get-str node "label"))))))) | ||
| 140 | + | ||
| 141 | +(deftest a_changed_tag_replaces_the_widget | ||
| 142 | + (let [root (fresh-root) | ||
| 143 | + loading? (r/atom true)] | ||
| 144 | + (ui/mount root :window [(fn [] (if @loading? | ||
| 145 | + [:spinner {:label "…"}] | ||
| 146 | + [:label {:label "done"}]))]) | ||
| 147 | + (let [before (first (children root))] | ||
| 148 | + (is (= "spinner" (ffi/node-tag before))) | ||
| 149 | + (reset! loading? false) | ||
| 150 | + (is (= ["label"] (tags root))) | ||
| 151 | + (is (false? (ffi/node-exists? before)))))) | ||
| 152 | + | ||
| 153 | +(deftest a_keyed_list_reuses_widgets_across_a_reorder | ||
| 154 | + (let [root (fresh-root) | ||
| 155 | + items (r/atom [:a :b :c])] | ||
| 156 | + (ui/mount root :window | ||
| 157 | + [(fn [] | ||
| 158 | + (into [:vbox {}] | ||
| 159 | + (for [item @items] | ||
| 160 | + ^{:key item} [:label {:label (name item)}])))]) | ||
| 161 | + (let [box (first (children root)) | ||
| 162 | + before (children box)] | ||
| 163 | + (is (= ["a" "b" "c"] (labels box))) | ||
| 164 | + (reset! items [:c :a :b]) | ||
| 165 | + (testing "the same three nodes, in a new order" | ||
| 166 | + (is (= ["c" "a" "b"] (labels box))) | ||
| 167 | + (is (= (set before) (set (children box))))) | ||
| 168 | + (testing "and a dropped key takes its widget with it" | ||
| 169 | + (reset! items [:c :b]) | ||
| 170 | + (is (= ["c" "b"] (labels box))) | ||
| 171 | + (is (= 2 (ffi/node-child-count box))))))) | ||
| 172 | + | ||
| 173 | +;; --- the hiccup dump --------------------------------------------------------- | ||
| 174 | +(deftest a_dump_reads_back_as_the_hiccup_that_was_mounted | ||
| 175 | + (let [root (fresh-root)] | ||
| 176 | + (ui/mount root :window [greeting "world"]) | ||
| 177 | + (testing "the tree comes back as data, one node per hiccup vector" | ||
| 178 | + ;; :vbox is a box with an orientation down there, and :spacing crossed as | ||
| 179 | + ;; a double — so this is the mounted hiccup, not a copy of its source. | ||
| 180 | + (is (= [:window {} | ||
| 181 | + [:box {:orientation "vertical" :spacing 6} | ||
| 182 | + [:title {:label "hello"}] | ||
| 183 | + [:label {:label "world"}]]] | ||
| 184 | + (backend/dump root)))) | ||
| 185 | + (testing "and as text, for a bug report" | ||
| 186 | + (is (= "[:window {}\n [:box {:orientation \"vertical\" :spacing 6}\n [:title {:label \"hello\"}]\n [:label {:label \"world\"}]]]" | ||
| 187 | + (backend/dump-str root)))))) | ||
| 188 | + | ||
| 189 | +(deftest a_dump_shows_a_reactive_change_and_hides_handlers | ||
| 190 | + (let [root (fresh-root) | ||
| 191 | + who (r/atom "world")] | ||
| 192 | + (ui/mount root :window [(fn [] [:button {:label @who :on-click (fn [] nil)}])]) | ||
| 193 | + (is (= [:window {} [:button {:label "world"}]] (backend/dump root))) | ||
| 194 | + (testing "the handler is not in it — it never crossed the boundary" | ||
| 195 | + (is (not (contains? (second (nth (backend/dump root) 2)) :on-click)))) | ||
| 196 | + (reset! who "glimmer") | ||
| 197 | + (is (= [:window {} [:button {:label "glimmer"}]] (backend/dump root))))) | ||
added
jolt/glimmer-vidya/test/glimmer_vidya/test_runner.jolt +41 -0 | new file mode 100644 | ||
| @@ -0,0 +1,41 @@ | ||
| 1 | +(ns glimmer-vidya.test-runner | |
| 2 | + "Entry point for `jolt -M:test`. Requires each test namespace, runs | |
| 3 | + clojure.test against it, and exits non-zero if anything failed. | |
| 4 | + | |
| 5 | + The suite is headless. It links libvidya and builds real node trees through | |
| 6 | + the FFI, but never opens a window: only `vidya_tree_frame` needs a GL | |
| 7 | + surface, so no display is involved." | |
| 8 | + (:require [clojure.test :as t])) | |
| 9 | + | |
| 10 | +;; Surface full causes on :error — the default report swallows the throwable. | |
| 11 | +(defmethod t/report :error [m] | |
| 12 | + (t/with-test-out | |
| 13 | + (t/inc-report-counter :error) | |
| 14 | + (println "\nERROR in" (t/testing-vars-str m)) | |
| 15 | + (when (seq t/*testing-contexts*) (println (t/testing-contexts-str))) | |
| 16 | + (when-let [message (:message m)] (println message)) | |
| 17 | + (when-let [e (:actual m)] | |
| 18 | + (if (instance? Throwable e) | |
| 19 | + (do (println " ->" (.getName (class e)) ":" (ex-message e)) | |
| 20 | + (when-let [d (ex-data e)] (prn d))) | |
| 21 | + (prn e))))) | |
| 22 | + | |
| 23 | +(defn- exit [code] | |
| 24 | + (cond | |
| 25 | + (resolve 'jolt.host/exit) ((resolve 'jolt.host/exit) code) | |
| 26 | + (resolve 'System/exit) ((resolve 'System/exit) code) | |
| 27 | + :else nil)) | |
| 28 | + | |
| 29 | +(defn -main [& _] | |
| 30 | + (let [namespaces '[glimmer-vidya.backend-test]] | |
| 31 | + (doseq [ns namespaces] | |
| 32 | + (try (require ns :reload) | |
| 33 | + (catch Exception e | |
| 34 | + (println "ERROR requiring" ns ":" (ex-message e))))) | |
| 35 | + (let [results (apply t/run-tests namespaces) | |
| 36 | + failed (+ (:fail results 0) (:error results 0))] | |
| 37 | + (println "----") | |
| 38 | + (println "tests:" (:test results 0) | |
| 39 | + "assertions:" (:pass results 0) "passed /" | |
| 40 | + failed "failed") | |
| 41 | + (when (pos? failed) (exit 1))))) | |
| new file mode 100644 | |||
| @@ -0,0 +1,41 @@ | |||
| 1 | +(ns glimmer-vidya.test-runner | ||
| 2 | + "Entry point for `jolt -M:test`. Requires each test namespace, runs | ||
| 3 | + clojure.test against it, and exits non-zero if anything failed. | ||
| 4 | + | ||
| 5 | + The suite is headless. It links libvidya and builds real node trees through | ||
| 6 | + the FFI, but never opens a window: only `vidya_tree_frame` needs a GL | ||
| 7 | + surface, so no display is involved." | ||
| 8 | + (:require [clojure.test :as t])) | ||
| 9 | + | ||
| 10 | +;; Surface full causes on :error — the default report swallows the throwable. | ||
| 11 | +(defmethod t/report :error [m] | ||
| 12 | + (t/with-test-out | ||
| 13 | + (t/inc-report-counter :error) | ||
| 14 | + (println "\nERROR in" (t/testing-vars-str m)) | ||
| 15 | + (when (seq t/*testing-contexts*) (println (t/testing-contexts-str))) | ||
| 16 | + (when-let [message (:message m)] (println message)) | ||
| 17 | + (when-let [e (:actual m)] | ||
| 18 | + (if (instance? Throwable e) | ||
| 19 | + (do (println " ->" (.getName (class e)) ":" (ex-message e)) | ||
| 20 | + (when-let [d (ex-data e)] (prn d))) | ||
| 21 | + (prn e))))) | ||
| 22 | + | ||
| 23 | +(defn- exit [code] | ||
| 24 | + (cond | ||
| 25 | + (resolve 'jolt.host/exit) ((resolve 'jolt.host/exit) code) | ||
| 26 | + (resolve 'System/exit) ((resolve 'System/exit) code) | ||
| 27 | + :else nil)) | ||
| 28 | + | ||
| 29 | +(defn -main [& _] | ||
| 30 | + (let [namespaces '[glimmer-vidya.backend-test]] | ||
| 31 | + (doseq [ns namespaces] | ||
| 32 | + (try (require ns :reload) | ||
| 33 | + (catch Exception e | ||
| 34 | + (println "ERROR requiring" ns ":" (ex-message e))))) | ||
| 35 | + (let [results (apply t/run-tests namespaces) | ||
| 36 | + failed (+ (:fail results 0) (:error results 0))] | ||
| 37 | + (println "----") | ||
| 38 | + (println "tests:" (:test results 0) | ||
| 39 | + "assertions:" (:pass results 0) "passed /" | ||
| 40 | + failed "failed") | ||
| 41 | + (when (pos? failed) (exit 1))))) | ||