nandi/jolt-nativepublic Fork 0
cfd3e36
Commits
Clone
git clone https://git.rickub.com/nandi/jolt-native.git
git clone ssh://git@rickub.com/nandi/jolt-native.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

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>
nandi committed 2026-08-30T16:53:54-07:00 Browse files
cfd3e36 parent: 90f8b89
modified Cargo.lock +1481 -54
@@ -2,6 +2,22 @@
22 # It is not intended for manual editing.
33 version = 4
44
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+
521 [[package]]
622 name = "addr2line"
723 version = "0.25.1"
@@ -52,6 +68,19 @@ dependencies = [
5268 "subtle",
5369 ]
5470
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+
5584 [[package]]
5685 name = "aho-corasick"
5786 version = "1.1.4"
@@ -89,6 +118,31 @@ dependencies = [
89118 "pkg-config",
90119 ]
91120
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+
92146 [[package]]
93147 name = "android_system_properties"
94148 version = "0.1.5"
@@ -188,6 +242,35 @@ version = "1.0.104"
188242 source = "registry+https://github.com/rust-lang/crates.io-index"
189243 checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
190244
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+
191274 [[package]]
192275 name = "archery"
193276 version = "1.2.2"
@@ -209,6 +292,12 @@ version = "0.7.8"
209292 source = "registry+https://github.com/rust-lang/crates.io-index"
210293 checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56"
211294
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+
212301 [[package]]
213302 name = "asn1-rs"
214303 version = "0.7.2"
@@ -385,7 +474,7 @@ dependencies = [
385474 "addr2line",
386475 "cfg-if",
387476 "libc",
388- "miniz_oxide",
477+ "miniz_oxide 0.8.9",
389478 "object",
390479 "rustc-demangle",
391480 "windows-link",
@@ -501,13 +590,22 @@ dependencies = [
501590 "hybrid-array",
502591 ]
503592
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+
504602 [[package]]
505603 name = "block2"
506604 version = "0.6.2"
507605 source = "registry+https://github.com/rust-lang/crates.io-index"
508606 checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5"
509607 dependencies = [
510- "objc2",
608+ "objc2 0.6.4",
511609 ]
512610
513611 [[package]]
@@ -575,6 +673,57 @@ dependencies = [
575673 "serde",
576674 ]
577675
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+
578727 [[package]]
579728 name = "cc"
580729 version = "1.4.0"
@@ -630,6 +779,15 @@ version = "0.2.2"
630779 source = "registry+https://github.com/rust-lang/crates.io-index"
631780 checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
632781
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+
633791 [[package]]
634792 name = "chacha20"
635793 version = "0.10.1"
@@ -714,6 +872,15 @@ version = "0.7.7"
714872 source = "registry+https://github.com/rust-lang/crates.io-index"
715873 checksum = "c3e64b0cc0439b12df2fa678eae89a1c56a529fd067a9115f7827f1fffd22b32"
716874
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+
717884 [[package]]
718885 name = "cmake"
719886 version = "0.1.58"
@@ -766,6 +933,15 @@ dependencies = [
766933 "memchr",
767934 ]
768935
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+
769945 [[package]]
770946 name = "conducer"
771947 version = "0.3.1"
@@ -817,6 +993,16 @@ dependencies = [
817993 "tracing",
818994 ]
819995
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+
8201006 [[package]]
8211007 name = "core-foundation"
8221008 version = "0.10.1"
@@ -833,6 +1019,30 @@ version = "0.8.7"
8331019 source = "registry+https://github.com/rust-lang/crates.io-index"
8341020 checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
8351021
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+
8361046 [[package]]
8371047 name = "core_affinity"
8381048 version = "0.8.3"
@@ -873,13 +1083,13 @@ dependencies = [
8731083 "ndk-context",
8741084 "num-derive",
8751085 "num-traits",
876- "objc2",
1086+ "objc2 0.6.4",
8771087 "objc2-audio-toolbox",
8781088 "objc2-avf-audio",
8791089 "objc2-core-audio",
8801090 "objc2-core-audio-types",
8811091 "objc2-core-foundation",
882- "objc2-foundation",
1092+ "objc2-foundation 0.3.2",
8831093 "pipewire",
8841094 "wasm-bindgen",
8851095 "wasm-bindgen-futures",
@@ -905,6 +1115,15 @@ dependencies = [
9051115 "libc",
9061116 ]
9071117
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+
9081127 [[package]]
9091128 name = "critical-section"
9101129 version = "1.2.0"
@@ -988,6 +1207,12 @@ dependencies = [
9881207 "cmov",
9891208 ]
9901209
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+
9911216 [[package]]
9921217 name = "curve25519-dalek"
9931218 version = "5.0.0-pre.1"
@@ -1163,6 +1388,17 @@ dependencies = [
11631388 "serde_core",
11641389 ]
11651390
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+
11661402 [[package]]
11671403 name = "derive_builder"
11681404 version = "0.20.2"
@@ -1244,6 +1480,12 @@ dependencies = [
12441480 "crypto-common 0.2.2",
12451481 ]
12461482
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+
12471489 [[package]]
12481490 name = "dispatch2"
12491491 version = "0.3.1"
@@ -1251,9 +1493,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
12511493 checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38"
12521494 dependencies = [
12531495 "bitflags 2.13.1",
1254- "block2",
1496+ "block2 0.6.2",
12551497 "libc",
1256- "objc2",
1498+ "objc2 0.6.4",
12571499 ]
12581500
12591501 [[package]]
@@ -1267,6 +1509,15 @@ dependencies = [
12671509 "syn 3.0.3",
12681510 ]
12691511
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+
12701521 [[package]]
12711522 name = "dlopen2"
12721523 version = "0.5.0"
@@ -1287,6 +1538,18 @@ dependencies = [
12871538 "litrs",
12881539 ]
12891540
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+
12901553 [[package]]
12911554 name = "dunce"
12921555 version = "1.0.5"
@@ -1299,6 +1562,16 @@ version = "1.0.20"
12991562 source = "registry+https://github.com/rust-lang/crates.io-index"
13001563 checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
13011564
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+
13021575 [[package]]
13031576 name = "ed25519"
13041577 version = "3.0.0-rc.4"
@@ -1326,12 +1599,72 @@ dependencies = [
13261599 "zeroize",
13271600 ]
13281601
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+
13291653 [[package]]
13301654 name = "either"
13311655 version = "1.17.0"
13321656 source = "registry+https://github.com/rust-lang/crates.io-index"
13331657 checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d"
13341658
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+
13351668 [[package]]
13361669 name = "embedded-io"
13371670 version = "0.4.0"
@@ -1410,6 +1743,30 @@ dependencies = [
14101743 "log",
14111744 ]
14121745
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+
14131770 [[package]]
14141771 name = "equivalent"
14151772 version = "1.0.2"
@@ -1426,6 +1783,12 @@ dependencies = [
14261783 "windows-sys 0.61.2",
14271784 ]
14281785
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+
14291792 [[package]]
14301793 name = "erydanos"
14311794 version = "0.2.18"
@@ -1465,6 +1828,21 @@ version = "2.5.0"
14651828 source = "registry+https://github.com/rust-lang/crates.io-index"
14661829 checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
14671830
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+
14681846 [[package]]
14691847 name = "fiat-crypto"
14701848 version = "0.3.0"
@@ -1501,6 +1879,17 @@ version = "0.5.7"
15011879 source = "registry+https://github.com/rust-lang/crates.io-index"
15021880 checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99"
15031881
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+
15041893 [[package]]
15051894 name = "fnv"
15061895 version = "1.0.7"
@@ -1513,6 +1902,33 @@ version = "0.2.0"
15131902 source = "registry+https://github.com/rust-lang/crates.io-index"
15141903 checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
15151904
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+
15161932 [[package]]
15171933 name = "form_urlencoded"
15181934 version = "1.2.2"
@@ -1680,6 +2096,16 @@ dependencies = [
16802096 "version_check",
16812097 ]
16822098
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+
16832109 [[package]]
16842110 name = "getrandom"
16852111 version = "0.2.17"
@@ -1737,6 +2163,17 @@ version = "0.32.3"
17372163 source = "registry+https://github.com/rust-lang/crates.io-index"
17382164 checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7"
17392165
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+
17402177 [[package]]
17412178 name = "glob"
17422179 version = "0.3.4"
@@ -1755,6 +2192,84 @@ dependencies = [
17552192 "wasm-bindgen",
17562193 ]
17572194
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+
17582273 [[package]]
17592274 name = "h2"
17602275 version = "0.4.15"
@@ -2224,6 +2739,8 @@ dependencies = [
22242739 "byteorder-lite",
22252740 "moxcms",
22262741 "num-traits",
2742+ "png 0.18.1",
2743+ "tiff",
22272744 "zune-core",
22282745 "zune-jpeg",
22292746 ]
@@ -2750,6 +3267,12 @@ dependencies = [
27503267 "wasm-bindgen",
27513268 ]
27523269
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+
27533276 [[package]]
27543277 name = "lazy_static"
27553278 version = "1.5.0"
@@ -2778,6 +3301,18 @@ version = "0.2.16"
27783301 source = "registry+https://github.com/rust-lang/crates.io-index"
27793302 checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
27803303
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+
27813316 [[package]]
27823317 name = "libspa"
27833318 version = "0.9.2"
@@ -2799,12 +3334,24 @@ dependencies = [
27993334 name = "libspa-sys"
28003335 version = "0.9.2"
28013336 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"
28083355
28093356 [[package]]
28103357 name = "litemap"
@@ -2891,6 +3438,24 @@ version = "2.8.3"
28913438 source = "registry+https://github.com/rust-lang/crates.io-index"
28923439 checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
28933440
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+
28943459 [[package]]
28953460 name = "minimal-lexical"
28963461 version = "0.2.1"
@@ -2904,6 +3469,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
29043469 checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
29053470 dependencies = [
29063471 "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",
29073483 ]
29083484
29093485 [[package]]
@@ -3024,6 +3600,20 @@ dependencies = [
30243600 "pxfm",
30253601 ]
30263602
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+
30273617 [[package]]
30283618 name = "n0-error"
30293619 version = "0.1.3"
@@ -3098,6 +3688,7 @@ dependencies = [
30983688 "log",
30993689 "ndk-sys",
31003690 "num_enum",
3691+ "raw-window-handle",
31013692 "thiserror 1.0.69",
31023693 ]
31033694
@@ -3122,7 +3713,7 @@ version = "0.40.1"
31223713 source = "registry+https://github.com/rust-lang/crates.io-index"
31233714 checksum = "1b0a0096d9613ee878dba89bbe595f079d373e3f1960d882e4f2f78ff9c30a0a"
31243715 dependencies = [
3125- "block2",
3716+ "block2 0.6.2",
31263717 "dispatch2",
31273718 "dlopen2",
31283719 "ipnet",
@@ -3247,6 +3838,12 @@ dependencies = [
32473838 "libc",
32483839 ]
32493840
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+
32503847 [[package]]
32513848 name = "nom"
32523849 version = "7.1.3"
@@ -3404,6 +4001,18 @@ dependencies = [
34044001 "num-traits",
34054002 ]
34064003
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+
34074016 [[package]]
34084017 name = "num-traits"
34094018 version = "0.2.19"
@@ -3454,6 +4063,22 @@ dependencies = [
34544063 "libc",
34554064 ]
34564065
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+
34574082 [[package]]
34584083 name = "objc2"
34594084 version = "0.6.4"
@@ -3463,6 +4088,35 @@ dependencies = [
34634088 "objc2-encode",
34644089 ]
34654090
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+
34664120 [[package]]
34674121 name = "objc2-audio-toolbox"
34684122 version = "0.3.2"
@@ -3471,11 +4125,11 @@ checksum = "6948501a91121d6399b79abaa33a8aa4ea7857fe019f341b8c23ad6e81b79b08"
34714125 dependencies = [
34724126 "bitflags 2.13.1",
34734127 "libc",
3474- "objc2",
4128+ "objc2 0.6.4",
34754129 "objc2-core-audio",
34764130 "objc2-core-audio-types",
34774131 "objc2-core-foundation",
3478- "objc2-foundation",
4132+ "objc2-foundation 0.3.2",
34794133 ]
34804134
34814135 [[package]]
@@ -3485,19 +4139,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
34854139 checksum = "478ae33fcac9df0a18db8302387c666b8ef08a3e2d62b510ca4fc278a384b6c0"
34864140 dependencies = [
34874141 "bitflags 2.13.1",
3488- "block2",
4142+ "block2 0.6.2",
34894143 "dispatch2",
3490- "objc2",
4144+ "objc2 0.6.4",
34914145 "objc2-avf-audio",
34924146 "objc2-core-audio-types",
34934147 "objc2-core-foundation",
34944148 "objc2-core-graphics",
3495- "objc2-core-image",
4149+ "objc2-core-image 0.3.2",
34964150 "objc2-core-video",
3497- "objc2-foundation",
4151+ "objc2-foundation 0.3.2",
34984152 "objc2-image-io",
34994153 "objc2-media-toolbox",
3500- "objc2-quartz-core",
4154+ "objc2-quartz-core 0.3.2",
35014155 ]
35024156
35034157 [[package]]
@@ -3506,8 +4160,32 @@ version = "0.3.2"
35064160 source = "registry+https://github.com/rust-lang/crates.io-index"
35074161 checksum = "13a380031deed8e99db00065c45937da434ca987c034e13b87e4441f9e4090be"
35084162 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",
35114189 ]
35124190
35134191 [[package]]
@@ -3517,10 +4195,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
35174195 checksum = "e1eebcea8b0dbff5f7c8504f3107c68fc061a3eb44932051c8cf8a68d969c3b2"
35184196 dependencies = [
35194197 "dispatch2",
3520- "objc2",
4198+ "objc2 0.6.4",
35214199 "objc2-core-audio-types",
35224200 "objc2-core-foundation",
3523- "objc2-foundation",
4201+ "objc2-foundation 0.3.2",
35244202 ]
35254203
35264204 [[package]]
@@ -3530,7 +4208,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
35304208 checksum = "5a89f2ec274a0cf4a32642b2991e8b351a404d290da87bb6a9a9d8632490bd1c"
35314209 dependencies = [
35324210 "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",
35344224 ]
35354225
35364226 [[package]]
@@ -3540,10 +4230,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
35404230 checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"
35414231 dependencies = [
35424232 "bitflags 2.13.1",
3543- "block2",
4233+ "block2 0.6.2",
35444234 "dispatch2",
35454235 "libc",
3546- "objc2",
4236+ "objc2 0.6.4",
35474237 ]
35484238
35494239 [[package]]
@@ -3554,19 +4244,43 @@ checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807"
35544244 dependencies = [
35554245 "bitflags 2.13.1",
35564246 "dispatch2",
3557- "objc2",
4247+ "objc2 0.6.4",
35584248 "objc2-core-foundation",
35594249 "objc2-io-surface",
35604250 ]
35614251
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+
35624264 [[package]]
35634265 name = "objc2-core-image"
35644266 version = "0.3.2"
35654267 source = "registry+https://github.com/rust-lang/crates.io-index"
35664268 checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006"
35674269 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",
35704284 ]
35714285
35724286 [[package]]
@@ -3576,9 +4290,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
35764290 checksum = "05ec576860167a15dd9fce7fbee7512beb4e31f532159d3482d1f9c6caedf31d"
35774291 dependencies = [
35784292 "bitflags 2.13.1",
3579- "block2",
4293+ "block2 0.6.2",
35804294 "dispatch2",
3581- "objc2",
4295+ "objc2 0.6.4",
35824296 "objc2-core-audio",
35834297 "objc2-core-audio-types",
35844298 "objc2-core-foundation",
@@ -3592,12 +4306,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
35924306 checksum = "d425caf1df73233f29fd8a5c3e5edbc30d2d4307870f802d18f00d83dc5141a6"
35934307 dependencies = [
35944308 "bitflags 2.13.1",
3595- "block2",
3596- "objc2",
4309+ "block2 0.6.2",
4310+ "objc2 0.6.4",
35974311 "objc2-core-foundation",
35984312 "objc2-core-graphics",
35994313 "objc2-io-surface",
3600- "objc2-metal",
4314+ "objc2-metal 0.3.2",
36014315 ]
36024316
36034317 [[package]]
@@ -3606,6 +4320,19 @@ version = "4.1.0"
36064320 source = "registry+https://github.com/rust-lang/crates.io-index"
36074321 checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33"
36084322
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+
36094336 [[package]]
36104337 name = "objc2-foundation"
36114338 version = "0.3.2"
@@ -3613,9 +4340,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
36134340 checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
36144341 dependencies = [
36154342 "bitflags 2.13.1",
3616- "block2",
4343+ "block2 0.6.2",
36174344 "libc",
3618- "objc2",
4345+ "objc2 0.6.4",
36194346 "objc2-core-foundation",
36204347 ]
36214348
@@ -3625,7 +4352,7 @@ version = "0.3.2"
36254352 source = "registry+https://github.com/rust-lang/crates.io-index"
36264353 checksum = "32b0446e98cf4a784cc7a0177715ff317eeaa8463841c616cfc78aa4f953c4ea"
36274354 dependencies = [
3628- "objc2",
4355+ "objc2 0.6.4",
36294356 "objc2-core-foundation",
36304357 "objc2-core-graphics",
36314358 ]
@@ -3637,22 +4364,46 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
36374364 checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d"
36384365 dependencies = [
36394366 "bitflags 2.13.1",
3640- "objc2",
4367+ "objc2 0.6.4",
36414368 "objc2-core-foundation",
36424369 ]
36434370
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+
36444383 [[package]]
36454384 name = "objc2-media-toolbox"
36464385 version = "0.3.2"
36474386 source = "registry+https://github.com/rust-lang/crates.io-index"
36484387 checksum = "edd9fdde720df3da7046bb9097811000c1e7ab5cd579fa89d96b27d56781fb30"
36494388 dependencies = [
3650- "objc2",
4389+ "objc2 0.6.4",
36514390 "objc2-core-audio-types",
36524391 "objc2-core-foundation",
36534392 "objc2-core-media",
36544393 ]
36554394
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+
36564407 [[package]]
36574408 name = "objc2-metal"
36584409 version = "0.3.2"
@@ -3660,8 +4411,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
36604411 checksum = "a0125f776a10d00af4152d74616409f0d4a2053a6f57fa5b7d6aa2854ac04794"
36614412 dependencies = [
36624413 "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",
36654429 ]
36664430
36674431 [[package]]
@@ -3671,8 +4435,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
36714435 checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f"
36724436 dependencies = [
36734437 "bitflags 2.13.1",
3674- "objc2",
3675- "objc2-foundation",
4438+ "objc2 0.6.4",
4439+ "objc2-foundation 0.3.2",
36764440 ]
36774441
36784442 [[package]]
@@ -3682,10 +4446,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
36824446 checksum = "709fe137109bd1e8b5a99390f77a7d8b2961dafc1a1c5db8f2e60329ad6d895a"
36834447 dependencies = [
36844448 "bitflags 2.13.1",
3685- "objc2",
4449+ "objc2 0.6.4",
36864450 "objc2-core-foundation",
36874451 ]
36884452
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+
36894463 [[package]]
36904464 name = "objc2-system-configuration"
36914465 version = "0.3.2"
@@ -3695,11 +4469,56 @@ dependencies = [
36954469 "bitflags 2.13.1",
36964470 "dispatch2",
36974471 "libc",
3698- "objc2",
4472+ "objc2 0.6.4",
36994473 "objc2-core-foundation",
37004474 "objc2-security",
37014475 ]
37024476
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+
37034522 [[package]]
37044523 name = "object"
37054524 version = "0.37.3"
@@ -3767,6 +4586,25 @@ version = "0.2.1"
37674586 source = "registry+https://github.com/rust-lang/crates.io-index"
37684587 checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
37694588
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+
37704608 [[package]]
37714609 name = "papaya"
37724610 version = "0.2.4"
@@ -3803,7 +4641,7 @@ dependencies = [
38034641 "cfg-if",
38044642 "libc",
38054643 "petgraph",
3806- "redox_syscall",
4644+ "redox_syscall 0.5.18",
38074645 "smallvec",
38084646 "windows-link",
38094647 ]
@@ -3957,6 +4795,12 @@ version = "0.3.33"
39574795 source = "registry+https://github.com/rust-lang/crates.io-index"
39584796 checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
39594797
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+
39604804 [[package]]
39614805 name = "plist"
39624806 version = "1.10.0"
@@ -3970,6 +4814,46 @@ dependencies = [
39704814 "time",
39714815 ]
39724816
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+
39734857 [[package]]
39744858 name = "polyval"
39754859 version = "0.6.2"
@@ -4116,6 +5000,12 @@ dependencies = [
41165000 "unicode-ident",
41175001 ]
41185002
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+
41195009 [[package]]
41205010 name = "pxfm"
41215011 version = "0.1.30"
@@ -4140,6 +5030,12 @@ dependencies = [
41405030 "web-transport-trait",
41415031 ]
41425032
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+
41435039 [[package]]
41445040 name = "quick-xml"
41455041 version = "0.41.0"
@@ -4314,6 +5210,12 @@ dependencies = [
43145210 "rand_core 0.10.1",
43155211 ]
43165212
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+
43175219 [[package]]
43185220 name = "rayon"
43195221 version = "1.12.0"
@@ -4356,6 +5258,15 @@ dependencies = [
43565258 "rustfft",
43575259 ]
43585260
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+
43595270 [[package]]
43605271 name = "redox_syscall"
43615272 version = "0.5.18"
@@ -4365,6 +5276,15 @@ dependencies = [
43655276 "bitflags 2.13.1",
43665277 ]
43675278
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+
43685288 [[package]]
43695289 name = "ref-cast"
43705290 version = "1.0.26"
@@ -4603,6 +5523,32 @@ dependencies = [
46035523 "nom 7.1.3",
46045524 ]
46055525
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+
46065552 [[package]]
46075553 name = "rustls"
46085554 version = "0.23.43"
@@ -4655,7 +5601,7 @@ version = "0.6.2"
46555601 source = "registry+https://github.com/rust-lang/crates.io-index"
46565602 checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784"
46575603 dependencies = [
4658- "core-foundation",
5604+ "core-foundation 0.10.1",
46595605 "core-foundation-sys",
46605606 "jni 0.21.1",
46615607 "log",
@@ -4676,7 +5622,7 @@ version = "0.7.0"
46765622 source = "registry+https://github.com/rust-lang/crates.io-index"
46775623 checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0"
46785624 dependencies = [
4679- "core-foundation",
5625+ "core-foundation 0.10.1",
46805626 "core-foundation-sys",
46815627 "jni 0.22.4",
46825628 "log",
@@ -4721,18 +5667,18 @@ version = "0.1.0"
47215667 source = "git+https://github.com/n0-computer/iroh-live?rev=edd9bcc530a615f3ab3a6919e96790a077905376#edd9bcc530a615f3ab3a6919e96790a077905376"
47225668 dependencies = [
47235669 "anyhow",
4724- "block2",
5670+ "block2 0.6.2",
47255671 "bytes",
47265672 "derive_more",
47275673 "dispatch2",
47285674 "image",
47295675 "libc",
47305676 "nix 0.30.1",
4731- "objc2",
5677+ "objc2 0.6.4",
47325678 "objc2-av-foundation",
47335679 "objc2-core-media",
47345680 "objc2-core-video",
4735- "objc2-foundation",
5681+ "objc2-foundation 0.3.2",
47365682 "rusty-codecs",
47375683 "tracing",
47385684 "v4l2r",
@@ -4829,7 +5775,20 @@ checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294"
48295775 name = "scopeguard"
48305776 version = "1.2.0"
48315777 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+]
48335792
48345793 [[package]]
48355794 name = "security-framework"
@@ -4838,7 +5797,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
48385797 checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
48395798 dependencies = [
48405799 "bitflags 2.13.1",
4841- "core-foundation",
5800+ "core-foundation 0.10.1",
48425801 "core-foundation-sys",
48435802 "libc",
48445803 "security-framework-sys",
@@ -5073,6 +6032,12 @@ version = "3.0.0-rc.10"
50736032 source = "registry+https://github.com/rust-lang/crates.io-index"
50746033 checksum = "7f1880df446116126965eeec169136b2e0251dba37c6223bcc819569550edea3"
50756034
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+
50766041 [[package]]
50776042 name = "simd_cesu8"
50786043 version = "1.2.0"
@@ -5110,12 +6075,93 @@ version = "0.4.12"
51106075 source = "registry+https://github.com/rust-lang/crates.io-index"
51116076 checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
51126077
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+
51136087 [[package]]
51146088 name = "smallvec"
51156089 version = "1.15.2"
51166090 source = "registry+https://github.com/rust-lang/crates.io-index"
51176091 checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
51186092
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+
51196165 [[package]]
51206166 name = "socket2"
51216167 version = "0.6.5"
@@ -5253,6 +6299,12 @@ version = "0.2.4"
52536299 source = "registry+https://github.com/rust-lang/crates.io-index"
52546300 checksum = "fe895eb47f22e2ddd4dabc02bce419d2e643c8e3b585c78158b349195bc24d82"
52556301
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+
52566308 [[package]]
52576309 name = "strsim"
52586310 version = "0.11.1"
@@ -5486,6 +6538,20 @@ dependencies = [
54866538 "tracing",
54876539 ]
54886540
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+
54896555 [[package]]
54906556 name = "time"
54916557 version = "0.3.54"
@@ -5519,6 +6585,31 @@ dependencies = [
55196585 "time-core",
55206586 ]
55216587
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+
55226613 [[package]]
55236614 name = "tinystr"
55246615 version = "0.8.3"
@@ -5828,6 +6919,12 @@ version = "0.2.5"
58286919 source = "registry+https://github.com/rust-lang/crates.io-index"
58296920 checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
58306921
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+
58316928 [[package]]
58326929 name = "tungstenite"
58336930 version = "0.28.0"
@@ -6040,6 +7137,35 @@ version = "0.9.5"
60407137 source = "registry+https://github.com/rust-lang/crates.io-index"
60417138 checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
60427139
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+
60437169 [[package]]
60447170 name = "visibility"
60457171 version = "0.1.1"
@@ -6166,6 +7292,141 @@ dependencies = [
61667292 "wasm-bindgen",
61677293 ]
61687294
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+
61697430 [[package]]
61707431 name = "web-async"
61717432 version = "0.1.5"
@@ -6255,6 +7516,22 @@ dependencies = [
62557516 "bytes",
62567517 ]
62577518
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+
62587535 [[package]]
62597536 name = "webpki-root-certs"
62607537 version = "1.0.9"
@@ -6273,6 +7550,12 @@ dependencies = [
62737550 "rustls-pki-types",
62747551 ]
62757552
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+
62767559 [[package]]
62777560 name = "wide"
62787561 version = "1.5.0"
@@ -6607,6 +7890,58 @@ version = "0.52.6"
66077890 source = "registry+https://github.com/rust-lang/crates.io-index"
66087891 checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
66097892
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+
66107945 [[package]]
66117946 name = "winnow"
66127947 version = "1.0.4"
@@ -6662,6 +7997,38 @@ dependencies = [
66627997 "web-sys",
66637998 ]
66647999
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+
66658032 [[package]]
66668033 name = "x509-parser"
66678034 version = "0.18.1"
@@ -6680,6 +8047,31 @@ dependencies = [
66808047 "time",
66818048 ]
66828049
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+
66838075 [[package]]
66848076 name = "xml-rs"
66858077 version = "0.8.28"
@@ -6837,12 +8229,47 @@ dependencies = [
68378229 "syn 2.0.119",
68388230 ]
68398231
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+
68408255 [[package]]
68418256 name = "zmij"
68428257 version = "1.0.23"
68438258 source = "registry+https://github.com/rust-lang/crates.io-index"
68448259 checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
68458260
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+
68468273 [[package]]
68478274 name = "zune-core"
68488275 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 = 43 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 @@
1818 # no harder than naming one.
1919 [workspace]
2020 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+]
2227 # The vendored cpal is a patch source, not something this workspace develops.
2328 # Without excluding it, `cargo test --workspace` builds cpal's own default
2429 # feature set — which reaches for the Windows ASIO SDK and fails on Linux.
@@ -30,6 +35,12 @@ edition = "2021"
3035 license = "MIT"
3136 repository = "https://gitlab.com/nandithebull/jolt-native"
3237
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+
3344 [workspace.dependencies]
3445 jolt-abi = { path = "crates/jolt-abi" }
3546 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 default28 # 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
1010 anything else would mean writing them badly.
1111
1212 ```
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
1518 ```
1619
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+
1724 ## Why a workspace and not one library with features
1825
1926 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
101108 next step and has not been taken yet; until it is, a fix made in one place
102109 needs making in the other.
103110
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+
104128 ## Adding a crate
105129
106130 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 edge13+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, capture14+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 features24 ## Why a workspace and not one library with features
18 25
19 Because cargo features are additive *within* a library. A single26 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 place108 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 crate128 ## Adding a crate
105 129
106 One crate, one `.so`, one capability. Depend on `jolt-abi` and keep its three130 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+2500U+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+2500U+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 differBinary 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 differBinary 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)))))