diff --git a/Cargo.lock b/Cargo.lock index 731a2e4..537d306 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -107,8 +107,8 @@ dependencies = [ "accesskit_consumer", "hashbrown 0.16.1", "static_assertions", - "windows", - "windows-core", + "windows 0.62.2", + "windows-core 0.62.2", ] [[package]] @@ -179,6 +179,15 @@ dependencies = [ "thiserror 2.0.21", ] +[[package]] +name = "android-build" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9fc9904ad2ad097c3c1cfe2eacaaf0fc24710936fa9ed941cb310b7c6ed2ab7" +dependencies = [ + "windows-sys 0.52.0", +] + [[package]] name = "android-properties" version = "0.2.2" @@ -584,15 +593,30 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec 0.8.0", +] + [[package]] name = "bit-set" version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2f926cc3060f09db9ebc5b52823d85268d24bb917e472c0c4bea35780a7d" dependencies = [ - "bit-vec", + "bit-vec 0.9.1", ] +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + [[package]] name = "bit-vec" version = "0.9.1" @@ -614,6 +638,12 @@ dependencies = [ "serde_core", ] +[[package]] +name = "block" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a" + [[package]] name = "block-buffer" version = "0.10.4" @@ -746,6 +776,18 @@ dependencies = [ "wayland-client", ] +[[package]] +name = "calloop-wayland-source" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "138efcf0940a02ebf0cc8d1eff41a1682a46b431630f4c52450d6265876021fa" +dependencies = [ + "calloop 0.14.5", + "rustix 1.1.6", + "wayland-backend", + "wayland-client", +] + [[package]] name = "cc" version = "1.10.0" @@ -833,6 +875,36 @@ dependencies = [ "error-code", ] +[[package]] +name = "clipboard_macos" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b7f4aaa047ba3c3630b080bb9860894732ff23e2aee290a418909aa6d5df38f" +dependencies = [ + "objc2 0.5.3", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "clipboard_wayland" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "003f886bc4e2987729d10c1db3424e7f80809f3fc22dbc16c685738887cb37b8" +dependencies = [ + "smithay-clipboard", +] + +[[package]] +name = "clipboard_x11" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd63e33452ffdafd39924c4f05a5dd1e94db646c779c6bd59148a3d95fff5ad4" +dependencies = [ + "thiserror 2.0.21", + "x11rb", +] + [[package]] name = "clru" version = "0.6.3" @@ -851,6 +923,17 @@ dependencies = [ "cc", ] +[[package]] +name = "codespan-reporting" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe6d2e5af09e8c8ad56c969f2157a3d4238cebc7c55f0a517728c38f7b200f81" +dependencies = [ + "serde", + "termcolor", + "unicode-width", +] + [[package]] name = "codespan-reporting" version = "0.13.1" @@ -958,7 +1041,7 @@ checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081" dependencies = [ "bitflags 1.3.2", "core-foundation 0.9.4", - "core-graphics-types", + "core-graphics-types 0.1.3", "foreign-types", "libc", ] @@ -974,12 +1057,56 @@ dependencies = [ "libc", ] +[[package]] +name = "core-graphics-types" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" +dependencies = [ + "bitflags 2.13.2", + "core-foundation 0.10.1", + "libc", +] + [[package]] name = "core_detect" version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48" +[[package]] +name = "core_maths" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77745e017f5edba1a9c1d854f6f3a52dac8a12dd5af5d2f54aecf61e43d80d30" +dependencies = [ + "libm", +] + +[[package]] +name = "cosmic-text" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "173852283a9a57a3cbe365d86e74dc428a09c50421477d5ad6fe9d9509e37737" +dependencies = [ + "bitflags 2.13.2", + "fontdb 0.23.0", + "harfrust 0.3.2", + "linebender_resource_handle", + "log", + "rangemap", + "rustc-hash 1.1.0", + "self_cell", + "skrifa 0.37.0", + "smol_str 0.2.2", + "swash", + "sys-locale", + "unicode-bidi", + "unicode-linebreak", + "unicode-script", + "unicode-segmentation", +] + [[package]] name = "countme" version = "3.0.1" @@ -1068,6 +1195,19 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" +[[package]] +name = "cryoglyph" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08bc795bdbccdbd461736fb163930a009da6597b226d6f6fce33e7a8eb6ec519" +dependencies = [ + "cosmic-text", + "etagere", + "lru", + "rustc-hash 2.1.3", + "wgpu 27.0.1", +] + [[package]] name = "crypto-common" version = "0.1.7" @@ -1356,6 +1496,16 @@ version = "3.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b5343afd4a8365a643ac588dab4cf234a190c7f6c88c9f6dd6ffe00837661b7" +[[package]] +name = "etagere" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc89bf99e5dc15954a60f707c1e09d7540e5cd9af85fa75caa0b510bc08c5342" +dependencies = [ + "euclid", + "svg_fmt", +] + [[package]] name = "euclid" version = "0.22.14" @@ -1409,7 +1559,7 @@ dependencies = [ "bitflags 2.13.2", "bytemuck", "fnv", - "glow", + "glow 0.18.0", "imgref", "itertools 0.15.0", "log", @@ -1498,6 +1648,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" +[[package]] +name = "font-types" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39a654f404bbcbd48ea58c617c2993ee91d1cb63727a37bf2323a4edeed1b8c5" +dependencies = [ + "bytemuck", +] + [[package]] name = "font-types" version = "0.12.7" @@ -1507,6 +1666,29 @@ dependencies = [ "bytemuck", ] +[[package]] +name = "fontconfig-parser" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbc773e24e02d4ddd8395fd30dc147524273a83e54e0f312d986ea30de5f5646" +dependencies = [ + "roxmltree 0.20.0", +] + +[[package]] +name = "fontdb" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "457e789b3d1202543297a350643cf459f836cade38934e7a4cf6a39e7cde2905" +dependencies = [ + "fontconfig-parser", + "log", + "memmap2", + "slotmap", + "tinyvec", + "ttf-parser", +] + [[package]] name = "fontdb" version = "0.24.0" @@ -1532,11 +1714,11 @@ dependencies = [ "objc2-core-text", "objc2-foundation 0.3.2", "parlance", - "read-fonts", - "roxmltree", + "read-fonts 0.41.0", + "roxmltree 0.21.2", "smallvec", - "windows", - "windows-core", + "windows 0.62.2", + "windows-core 0.62.2", "yeslogic-fontconfig-sys", ] @@ -1774,6 +1956,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "gif" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ae047235e33e2829703574b54fdec96bfbad892062d97fed2f76022287de61b" +dependencies = [ + "color_quant", + "weezl", +] + [[package]] name = "gif" version = "0.14.2" @@ -1795,6 +1987,12 @@ dependencies = [ "xml-rs", ] +[[package]] +name = "glam" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "151665d9be52f9bb40fc7966565d39666f2d1e69233571b71b87791c7e0528b3" + [[package]] name = "glob" version = "0.3.4" @@ -1813,6 +2011,18 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "glow" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5e5ea60d70410161c8bf5da3fdfeaa1c72ed2c15f8bbb9d19fe3a4fad085f08" +dependencies = [ + "js-sys", + "slotmap", + "wasm-bindgen", + "web-sys", +] + [[package]] name = "glow" version = "0.18.0" @@ -1891,6 +2101,37 @@ dependencies = [ "gl_generator", ] +[[package]] +name = "gpu-alloc" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45cf04b2726f02df5508c6de726acdc90cdf97ac771a9a0ffd8ba10a6e696bf9" +dependencies = [ + "bitflags 2.13.2", + "gpu-alloc-types", +] + +[[package]] +name = "gpu-alloc-types" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2bbed164dd10ed526c2e4fe3e721ca4a71c61730e5aafac6844b417b3227058" +dependencies = [ + "bitflags 2.13.2", +] + +[[package]] +name = "gpu-allocator" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c151a2a5ef800297b4e79efa4f4bec035c5f51d5ae587287c9b952bdf734cacd" +dependencies = [ + "log", + "presser", + "thiserror 1.0.69", + "windows 0.58.0", +] + [[package]] name = "gpu-allocator" version = "0.28.0" @@ -1902,7 +2143,27 @@ dependencies = [ "log", "presser", "thiserror 2.0.21", - "windows", + "windows 0.62.2", +] + +[[package]] +name = "gpu-descriptor" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b89c83349105e3732062a895becfc71a8f921bb71ecbbdd8ff99263e3b53a0ca" +dependencies = [ + "bitflags 2.13.2", + "gpu-descriptor-types", + "hashbrown 0.15.5", +] + +[[package]] +name = "gpu-descriptor-types" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdf242682df893b86f33a73828fb09ca4b2d3bb6cc95249707fc684d27484b91" +dependencies = [ + "bitflags 2.13.2", ] [[package]] @@ -1911,6 +2172,16 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b40ca9252762c466af32d0b1002e91e4e1bc5398f77455e55474deb466355ff5" +[[package]] +name = "guillotiere" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b62d5865c036cb1393e23c50693df631d3f5d7bcca4c04fe4cc0fd592e74a782" +dependencies = [ + "euclid", + "svg_fmt", +] + [[package]] name = "h2" version = "0.4.20" @@ -1942,6 +2213,19 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "harfrust" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92c020db12c71d8a12a3fe7607873cade3a01a6287e29d540c8723276221b9d8" +dependencies = [ + "bitflags 2.13.2", + "bytemuck", + "core_maths", + "read-fonts 0.35.0", + "smallvec", +] + [[package]] name = "harfrust" version = "0.12.0" @@ -1950,7 +2234,7 @@ checksum = "c03d949a14aa089bbb282f7dd76a498a7f684428e4257202efc119ec010376f9" dependencies = [ "bitflags 2.13.2", "bytemuck", - "read-fonts", + "read-fonts 0.41.0", "smallvec", ] @@ -2013,6 +2297,12 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hexf-parse" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df" + [[package]] name = "htmlparser" version = "0.2.1" @@ -2223,7 +2513,7 @@ dependencies = [ "wasm-bindgen", "web-sys", "webbrowser", - "windows", + "windows 0.62.2", "winit", "zbus", ] @@ -2241,8 +2531,8 @@ dependencies = [ "icu_locale_core", "icu_provider", "pulldown-cmark", - "resvg", - "skrifa", + "resvg 0.48.1", + "skrifa 0.44.0", ] [[package]] @@ -2267,10 +2557,10 @@ dependencies = [ "proc-macro2", "quote", "rayon", - "resvg", + "resvg 0.48.1", "rowan", "rspolib", - "skrifa", + "skrifa 0.44.0", "smol_str 0.3.7", "strum", "swash", @@ -2314,10 +2604,10 @@ dependencies = [ "pin-weak", "portable-atomic", "raw-window-handle", - "resvg", + "resvg 0.48.1", "rgb", "scopeguard", - "skrifa", + "skrifa 0.44.0", "slab", "strum", "swash", @@ -2330,8 +2620,8 @@ dependencies = [ "wasm-bindgen", "web-sys", "web-time", - "wgpu", - "windows", + "wgpu 30.0.1", + "windows 0.62.2", ] [[package]] @@ -2354,7 +2644,7 @@ dependencies = [ "cfg-if", "const-field-offset", "femtovg", - "glow", + "glow 0.18.0", "i-slint-common", "i-slint-core", "i-slint-core-macros", @@ -2377,7 +2667,7 @@ dependencies = [ "cfg_aliases", "clru", "const-field-offset", - "glow", + "glow 0.18.0", "glutin", "i-slint-common", "i-slint-core", @@ -2391,17 +2681,17 @@ dependencies = [ "objc2-quartz-core 0.3.2", "pin-weak", "raw-window-handle", - "read-fonts", + "read-fonts 0.41.0", "scoped-tls-hkt", "skia-safe", "softbuffer", "spin_on", "unicode-segmentation", "vtable", - "wgpu", - "wgpu-hal", - "windows", - "windows-core", + "wgpu 30.0.1", + "wgpu-hal 30.0.1", + "windows 0.62.2", + "windows-core 0.62.2", "write-fonts", ] @@ -2419,7 +2709,7 @@ dependencies = [ "i-slint-core", "lyon_path", "num-traits", - "skrifa", + "skrifa 0.44.0", "swash", "zeno", ] @@ -2436,7 +2726,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core", + "windows-core 0.62.2", ] [[package]] @@ -2448,6 +2738,192 @@ dependencies = [ "cc", ] +[[package]] +name = "iced" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "000e01026c93ba643f8357a3db3ada0e6555265a377f6f9291c472f6dd701fb3" +dependencies = [ + "iced_core", + "iced_debug", + "iced_futures", + "iced_renderer", + "iced_runtime", + "iced_widget", + "iced_winit", + "thiserror 2.0.21", +] + +[[package]] +name = "iced_core" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91ab1937d699403e7e69252ae743a902bcee9f4ab2052cc4c9a46fcf34729d85" +dependencies = [ + "bitflags 2.13.2", + "bytes", + "glam", + "lilt", + "log", + "num-traits", + "rustc-hash 2.1.3", + "smol_str 0.2.2", + "thiserror 2.0.21", + "web-time", +] + +[[package]] +name = "iced_debug" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25035ab0215a620e53f4103e36fc4e59a1fb2817e4bfc38a30ad27b4202ea0be" +dependencies = [ + "iced_core", + "iced_futures", + "log", +] + +[[package]] +name = "iced_futures" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c0c85ccad42dfbec7293c36c018af0ea0dbcc52d137a4a9a0b0f6822a3fdf0a" +dependencies = [ + "futures", + "iced_core", + "log", + "rustc-hash 2.1.3", + "wasm-bindgen-futures", + "wasmtimer", +] + +[[package]] +name = "iced_graphics" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234ca1c2cec4155055f68fa5fad1b5242c496ac8238d80a259bca382fb44a102" +dependencies = [ + "bitflags 2.13.2", + "bytemuck", + "cosmic-text", + "half", + "iced_core", + "iced_futures", + "log", + "raw-window-handle", + "rustc-hash 2.1.3", + "thiserror 2.0.21", + "unicode-segmentation", +] + +[[package]] +name = "iced_program" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dfafec2947cda688d8eb00dac337ba11aa60f9ef6335aed343e189d26e4a673" +dependencies = [ + "iced_graphics", + "iced_runtime", +] + +[[package]] +name = "iced_renderer" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "250cc0802408e8c077986ec56c7d07c65f423ee658a4b9fd795a1f2aae5dac05" +dependencies = [ + "iced_graphics", + "iced_tiny_skia", + "iced_wgpu", + "log", + "thiserror 2.0.21", +] + +[[package]] +name = "iced_runtime" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1889b819ce4c06674183242e336c8d49465665441396914dc07cc86f44fa8d4" +dependencies = [ + "bytes", + "iced_core", + "iced_futures", + "raw-window-handle", + "thiserror 2.0.21", +] + +[[package]] +name = "iced_tiny_skia" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c267596d742714b1853cc10c3983a367762816fc4836bd3b79f76ce76787d6f8" +dependencies = [ + "bytemuck", + "cosmic-text", + "iced_debug", + "iced_graphics", + "kurbo 0.10.4", + "log", + "resvg 0.45.1", + "rustc-hash 2.1.3", + "softbuffer", + "tiny-skia 0.11.4", +] + +[[package]] +name = "iced_wgpu" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff144a999b0ca0f8a10257934500060240825c42e950ec0ebee9c8ae30561c13" +dependencies = [ + "bitflags 2.13.2", + "bytemuck", + "cryoglyph", + "futures", + "glam", + "guillotiere", + "iced_debug", + "iced_graphics", + "log", + "resvg 0.45.1", + "rustc-hash 2.1.3", + "thiserror 2.0.21", + "wgpu 27.0.1", +] + +[[package]] +name = "iced_widget" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1596afa0d3109c2618e8bc12bae6c11d3064df8f95c42dfce570397dbe957ab" +dependencies = [ + "iced_renderer", + "log", + "num-traits", + "rustc-hash 2.1.3", + "thiserror 2.0.21", + "unicode-segmentation", +] + +[[package]] +name = "iced_winit" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7589888c8e951899cc688247a69933bb7a0511f0b4b2e122ac3fcd5dacb37f72" +dependencies = [ + "iced_debug", + "iced_program", + "log", + "mundy", + "rustc-hash 2.1.3", + "thiserror 2.0.21", + "tracing", + "wasm-bindgen-futures", + "web-sys", + "window_clipboard", + "winit", +] + [[package]] name = "icu_collections" version = "2.4.0" @@ -2656,9 +3132,9 @@ dependencies = [ "byteorder-lite", "moxcms", "num-traits", - "png", - "zune-core", - "zune-jpeg", + "png 0.18.1", + "zune-core 0.5.3", + "zune-jpeg 0.5.15", ] [[package]] @@ -2671,6 +3147,12 @@ dependencies = [ "quick-error", ] +[[package]] +name = "imagesize" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edcd27d72f2f071c64249075f42e205ff93c9a4c5f6c6da53e79ed9f9832c285" + [[package]] name = "imagesize" version = "0.15.0" @@ -2845,6 +3327,17 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "khronos-egl" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6aae1df220ece3c0ada96b8153459b67eebe9ae9212258bb0134ae60416fdf76" +dependencies = [ + "libc", + "libloading", + "pkg-config", +] + [[package]] name = "khronos_api" version = "3.1.0" @@ -2871,6 +3364,27 @@ dependencies = [ "zbus", ] +[[package]] +name = "kurbo" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1618d4ebd923e97d67e7cd363d80aef35fe961005cbbbb3d2dad8bdd1bc63440" +dependencies = [ + "arrayvec", + "smallvec", +] + +[[package]] +name = "kurbo" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c62026ae44756f8a599ba21140f350303d4f08dcdcc71b5ad9c9bb8128c13c62" +dependencies = [ + "arrayvec", + "euclid", + "smallvec", +] + [[package]] name = "kurbo" version = "0.13.1" @@ -2953,6 +3467,15 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "lilt" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "337d4c256f7d9f2dbd633891d48ace853efa5b1554122d9220b172ad9c03c3a9" +dependencies = [ + "web-time", +] + [[package]] name = "linebender_resource_handle" version = "0.1.1" @@ -3004,6 +3527,12 @@ version = "0.4.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" +[[package]] +name = "lru" +version = "0.16.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f66e8d5d03f609abc3a39e6f08e4164ebf1447a732906d39eb9b99b7919ef39" + [[package]] name = "lru-slab" version = "0.1.3" @@ -3051,6 +3580,15 @@ dependencies = [ "num-traits", ] +[[package]] +name = "malloc_buf" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb" +dependencies = [ + "libc", +] + [[package]] name = "matchit" version = "0.8.4" @@ -3091,6 +3629,21 @@ dependencies = [ "autocfg", ] +[[package]] +name = "metal" +version = "0.32.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00c15a6f673ff72ddcc22394663290f870fb224c1bfce55734a75c414150e605" +dependencies = [ + "bitflags 2.13.2", + "block", + "core-graphics-types 0.2.0", + "foreign-types", + "log", + "objc", + "paste", +] + [[package]] name = "mime" version = "0.3.17" @@ -3168,7 +3721,7 @@ dependencies = [ "objc2-core-foundation", "objc2-foundation 0.3.2", "once_cell", - "png", + "png 0.18.1", "thiserror 2.0.21", "windows-sys 0.61.2", ] @@ -3179,6 +3732,57 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d" +[[package]] +name = "mundy" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f32eb0db40f2df2bcfb05c93b8f73938d4c26ce9ac8881f1df0c8d3296921a73" +dependencies = [ + "android-build", + "async-io", + "cfg-if", + "dispatch", + "futures-channel", + "futures-lite", + "jni", + "ndk-context", + "objc2 0.6.5", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", + "pin-project-lite", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows 0.62.2", + "zbus", +] + +[[package]] +name = "naga" +version = "27.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "066cf25f0e8b11ee0df221219010f213ad429855f57c494f995590c861a9a7d8" +dependencies = [ + "arrayvec", + "bit-set 0.8.0", + "bitflags 2.13.2", + "cfg-if", + "cfg_aliases", + "codespan-reporting 0.12.0", + "half", + "hashbrown 0.16.1", + "hexf-parse", + "indexmap", + "libm", + "log", + "num-traits", + "once_cell", + "rustc-hash 1.1.0", + "spirv 0.3.0+sdk-1.3.268.0", + "thiserror 2.0.21", + "unicode-ident", +] + [[package]] name = "naga" version = "30.0.1" @@ -3186,11 +3790,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a616d2fb8c89516ac2723a581f69d6c18576046bed761bd6b305e5618e6ae130" dependencies = [ "arrayvec", - "bit-set", + "bit-set 0.10.0", "bitflags 2.13.2", "cfg-if", "cfg_aliases", - "codespan-reporting", + "codespan-reporting 0.13.1", "half", "hashbrown 0.17.1", "indexmap", @@ -3200,7 +3804,7 @@ dependencies = [ "num-traits", "once_cell", "rustc-hash 1.1.0", - "spirv", + "spirv 0.4.0+sdk-1.4.341.0", "thiserror 2.0.21", "unicode-ident", ] @@ -3316,6 +3920,15 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "objc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1" +dependencies = [ + "malloc_buf", +] + [[package]] name = "objc-sys" version = "0.3.5" @@ -3351,8 +3964,8 @@ dependencies = [ "block2 0.5.1", "libc", "objc2 0.5.3", - "objc2-core-data", - "objc2-core-image", + "objc2-core-data 0.2.2", + "objc2-core-image 0.2.2", "objc2-foundation 0.2.2", "objc2-quartz-core 0.2.2", ] @@ -3364,9 +3977,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" dependencies = [ "bitflags 2.13.2", + "block2 0.6.2", + "libc", "objc2 0.6.5", + "objc2-cloud-kit 0.3.2", + "objc2-core-data 0.3.2", "objc2-core-foundation", "objc2-core-graphics", + "objc2-core-image 0.3.2", + "objc2-core-text", + "objc2-core-video", "objc2-foundation 0.3.2", "objc2-quartz-core 0.3.2", ] @@ -3384,6 +4004,17 @@ dependencies = [ "objc2-foundation 0.2.2", ] +[[package]] +name = "objc2-cloud-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" +dependencies = [ + "bitflags 2.13.2", + "objc2 0.6.5", + "objc2-foundation 0.3.2", +] + [[package]] name = "objc2-contacts" version = "0.2.2" @@ -3407,6 +4038,17 @@ dependencies = [ "objc2-foundation 0.2.2", ] +[[package]] +name = "objc2-core-data" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" +dependencies = [ + "bitflags 2.13.2", + "objc2 0.6.5", + "objc2-foundation 0.3.2", +] + [[package]] name = "objc2-core-foundation" version = "0.3.2" @@ -3443,6 +4085,16 @@ dependencies = [ "objc2-metal 0.2.2", ] +[[package]] +name = "objc2-core-image" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006" +dependencies = [ + "objc2 0.6.5", + "objc2-foundation 0.3.2", +] + [[package]] name = "objc2-core-location" version = "0.2.2" @@ -3462,7 +4114,22 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" dependencies = [ "bitflags 2.13.2", + "objc2 0.6.5", "objc2-core-foundation", + "objc2-core-graphics", +] + +[[package]] +name = "objc2-core-video" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d425caf1df73233f29fd8a5c3e5edbc30d2d4307870f802d18f00d83dc5141a6" +dependencies = [ + "bitflags 2.13.2", + "objc2 0.6.5", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-io-surface", ] [[package]] @@ -3492,6 +4159,7 @@ checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ "bitflags 2.13.2", "block2 0.6.2", + "libc", "objc2 0.6.5", "objc2-core-foundation", ] @@ -3589,9 +4257,9 @@ dependencies = [ "bitflags 2.13.2", "block2 0.5.1", "objc2 0.5.3", - "objc2-cloud-kit", - "objc2-core-data", - "objc2-core-image", + "objc2-cloud-kit 0.2.2", + "objc2-core-data 0.2.2", + "objc2-core-image 0.2.2", "objc2-core-location", "objc2-foundation 0.2.2", "objc2-link-presentation", @@ -3746,7 +4414,7 @@ checksum = "22d2ff88bd3f7d68d1d9b09c7e6209f9a8e8c05088295140a2bcf2e9b17038c5" dependencies = [ "accesskit", "fontique", - "harfrust", + "harfrust 0.12.0", "hashbrown 0.17.1", "icu_normalizer", "icu_properties", @@ -3754,7 +4422,7 @@ dependencies = [ "linebender_resource_handle", "parlance", "parley_data", - "skrifa", + "skrifa 0.44.0", ] [[package]] @@ -3766,6 +4434,12 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + [[package]] name = "pastey" version = "0.2.3" @@ -3899,6 +4573,19 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" +[[package]] +name = "png" +version = "0.17.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" +dependencies = [ + "bitflags 1.3.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide 0.8.9", +] + [[package]] name = "png" version = "0.18.1" @@ -4184,6 +4871,12 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ca45419789ae5a7899559e9512e58ca889e41f04f1f2445e9f4b290ceccd1d08" +[[package]] +name = "rangemap" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a611d15b50743feb4c76b7d03edcb0e64f399c26961e4efe6975bc398be6aa3d" + [[package]] name = "raw-window-handle" version = "0.6.2" @@ -4222,6 +4915,17 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "read-fonts" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6717cf23b488adf64b9d711329542ba34de147df262370221940dfabc2c91358" +dependencies = [ + "bytemuck", + "core_maths", + "font-types 0.10.1", +] + [[package]] name = "read-fonts" version = "0.41.0" @@ -4229,7 +4933,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "046a7d674daf459825b32f5062056d6882db0d2f5a479fbd76ccfc870ac18709" dependencies = [ "bytemuck", - "font-types", + "font-types 0.12.7", "once_cell", ] @@ -4400,6 +5104,23 @@ dependencies = [ "web-sys", ] +[[package]] +name = "resvg" +version = "0.45.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8928798c0a55e03c9ca6c4c6846f76377427d2c1e1f7e6de3c06ae57942df43" +dependencies = [ + "gif 0.13.3", + "image-webp", + "log", + "pico-args", + "rgb", + "svgtypes 0.15.3", + "tiny-skia 0.11.4", + "usvg 0.45.1", + "zune-jpeg 0.4.21", +] + [[package]] name = "resvg" version = "0.48.1" @@ -4407,15 +5128,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67e3803f97b999e80cbf7c6ecdd07a8102204d92e1633cf48783720c521196bd" dependencies = [ "bytemuck", - "gif", + "gif 0.14.2", "image-webp", "log", "pico-args", "rgb", - "svgtypes", + "svgtypes 0.16.1", "tiny-skia 0.12.0", - "usvg", - "zune-jpeg", + "usvg 0.48.1", + "zune-jpeg 0.5.15", ] [[package]] @@ -4629,6 +5350,12 @@ dependencies = [ "text-size", ] +[[package]] +name = "roxmltree" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" + [[package]] name = "roxmltree" version = "0.21.2" @@ -4780,6 +5507,24 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" +[[package]] +name = "rustybuzz" +version = "0.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd3c7c96f8a08ee34eff8857b11b49b07d71d1c3f4e88f8a88d4c9e9f90b1702" +dependencies = [ + "bitflags 2.13.2", + "bytemuck", + "core_maths", + "log", + "smallvec", + "ttf-parser", + "unicode-bidi-mirroring", + "unicode-ccc", + "unicode-properties", + "unicode-script", +] + [[package]] name = "ryu" version = "1.0.23" @@ -4857,7 +5602,7 @@ dependencies = [ "ab_glyph", "log", "memmap2", - "smithay-client-toolkit", + "smithay-client-toolkit 0.19.2", "tiny-skia 0.11.4", ] @@ -4884,6 +5629,12 @@ dependencies = [ "libc", ] +[[package]] +name = "self_cell" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab42ca02749e120097e328d91d415325bdf43b1c72c4c8badf37375fe40a813" + [[package]] name = "semver" version = "1.0.28" @@ -5114,7 +5865,17 @@ checksum = "c61cb643dac43067f3eb76c866635a74377ac0abca0ff2590674e1e295514c3d" dependencies = [ "bitflags 2.13.2", "skia-bindings", - "windows", + "windows 0.62.2", +] + +[[package]] +name = "skrifa" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c31071dedf532758ecf3fed987cdb4bd9509f900e026ab684b4ecb81ea49841" +dependencies = [ + "bytemuck", + "read-fonts 0.35.0", ] [[package]] @@ -5124,7 +5885,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "819ab7d62b1d3e72d9d9dea5650bac30424f9111364bb94928dbf5ecad1baa68" dependencies = [ "bytemuck", - "read-fonts", + "read-fonts 0.41.0", ] [[package]] @@ -5203,7 +5964,7 @@ checksum = "3457dea1f0eb631b4034d61d4d8c32074caa6cd1ab2d59f2327bd8461e2c0016" dependencies = [ "bitflags 2.13.2", "calloop 0.13.0", - "calloop-wayland-source", + "calloop-wayland-source 0.3.0", "cursor-icon", "libc", "log", @@ -5220,6 +5981,44 @@ dependencies = [ "xkeysym", ] +[[package]] +name = "smithay-client-toolkit" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0512da38f5e2b31201a93524adb8d3136276fa4fe4aafab4e1f727a82b534cc0" +dependencies = [ + "bitflags 2.13.2", + "calloop 0.14.5", + "calloop-wayland-source 0.4.1", + "cursor-icon", + "libc", + "log", + "memmap2", + "rustix 1.1.6", + "thiserror 2.0.21", + "wayland-backend", + "wayland-client", + "wayland-csd-frame", + "wayland-cursor", + "wayland-protocols", + "wayland-protocols-experimental", + "wayland-protocols-misc", + "wayland-protocols-wlr", + "wayland-scanner", + "xkeysym", +] + +[[package]] +name = "smithay-clipboard" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71704c03f739f7745053bde45fa203a46c58d25bc5c4efba1d9a60e9dba81226" +dependencies = [ + "libc", + "smithay-client-toolkit 0.20.0", + "wayland-backend", +] + [[package]] name = "smol_str" version = "0.2.2" @@ -5310,6 +6109,15 @@ dependencies = [ "pin-utils", ] +[[package]] +name = "spirv" +version = "0.3.0+sdk-1.3.268.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eda41003dc44290527a59b13432d4a0379379fa074b70174882adfbdfd917844" +dependencies = [ + "bitflags 2.13.2", +] + [[package]] name = "spirv" version = "0.4.0+sdk-1.4.341.0" @@ -5386,13 +6194,29 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +[[package]] +name = "svg_fmt" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0193cc4331cfd2f3d2011ef287590868599a2f33c3e69bc22c1a3d3acf9e02fb" + +[[package]] +name = "svgtypes" +version = "0.15.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68c7541fff44b35860c1a7a47a7cadf3e4a304c457b58f9870d9706ece028afc" +dependencies = [ + "kurbo 0.11.3", + "siphasher", +] + [[package]] name = "svgtypes" version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "695b5790b3131dafa99b3bbfd25a216edb3d216dad9ca208d4657bfb8f2abc3d" dependencies = [ - "kurbo", + "kurbo 0.13.1", "siphasher", ] @@ -5402,7 +6226,7 @@ version = "0.2.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c2499c2d826531388872b2268718aed907a39bd785ab0dcfe57fab26283f92e" dependencies = [ - "skrifa", + "skrifa 0.44.0", "yazi", "zeno", ] @@ -5527,6 +6351,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + [[package]] name = "text-size" version = "1.1.1" @@ -5584,6 +6417,7 @@ dependencies = [ "bytemuck", "cfg-if", "log", + "png 0.17.16", "tiny-skia-path 0.11.4", ] @@ -5598,7 +6432,7 @@ dependencies = [ "bytemuck", "cfg-if", "log", - "png", + "png 0.18.1", "tiny-skia-path 0.12.0", ] @@ -5895,6 +6729,9 @@ name = "ttf-parser" version = "0.25.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" +dependencies = [ + "core_maths", +] [[package]] name = "tungstenite" @@ -5971,6 +6808,18 @@ version = "0.3.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" +[[package]] +name = "unicode-bidi-mirroring" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dfa6e8c60bb66d49db113e0125ee8711b7647b5579dc7f5f19c42357ed039fe" + +[[package]] +name = "unicode-ccc" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce61d488bcdc9bc8b5d1772c404828b17fc481c0a582b5581e95fb233aef503e" + [[package]] name = "unicode-ident" version = "1.0.27" @@ -5983,6 +6832,12 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" +[[package]] +name = "unicode-properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "993ad287eb20e8ba755312801cfe1413e0f25d604075863acaddca10b04c1833" + [[package]] name = "unicode-script" version = "0.5.8" @@ -6040,6 +6895,15 @@ dependencies = [ "unlucky-core", ] +[[package]] +name = "unlucky-app-iced" +version = "0.1.0" +dependencies = [ + "iced", + "image", + "unlucky-design", +] + [[package]] name = "unlucky-cli" version = "0.1.0" @@ -6104,6 +6968,33 @@ dependencies = [ "serde", ] +[[package]] +name = "usvg" +version = "0.45.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80be9b06fbae3b8b303400ab20778c80bbaf338f563afe567cf3c9eea17b47ef" +dependencies = [ + "base64 0.22.1", + "data-url", + "flate2", + "fontdb 0.23.0", + "imagesize 0.13.0", + "kurbo 0.11.3", + "log", + "pico-args", + "roxmltree 0.20.0", + "rustybuzz", + "simplecss", + "siphasher", + "strict-num", + "svgtypes 0.15.3", + "tiny-skia-path 0.11.4", + "unicode-bidi", + "unicode-script", + "unicode-vo", + "xmlwriter", +] + [[package]] name = "usvg" version = "0.48.1" @@ -6113,18 +7004,18 @@ dependencies = [ "base64 0.23.1", "data-url", "flate2", - "fontdb", - "harfrust", - "imagesize", - "kurbo", + "fontdb 0.24.0", + "harfrust 0.12.0", + "imagesize 0.15.0", + "kurbo 0.13.1", "log", "pico-args", - "roxmltree", + "roxmltree 0.21.2", "simplecss", "siphasher", - "skrifa", + "skrifa 0.44.0", "strict-num", - "svgtypes", + "svgtypes 0.16.1", "tiny-skia-path 0.12.0", "unicode-bidi", "unicode-script", @@ -6295,6 +7186,20 @@ dependencies = [ "web-sys", ] +[[package]] +name = "wasmtimer" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c598d6b99ea013e35844697fc4670d08339d5cda15588f193c6beedd12f644b" +dependencies = [ + "futures", + "js-sys", + "parking_lot", + "pin-utils", + "slab", + "wasm-bindgen", +] + [[package]] name = "wayland-backend" version = "0.3.17" @@ -6355,6 +7260,32 @@ dependencies = [ "wayland-scanner", ] +[[package]] +name = "wayland-protocols-experimental" +version = "20250721.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40a1f863128dcaaec790d7b4b396cc9b9a7a079e878e18c47e6c2d2c5a8dcbb1" +dependencies = [ + "bitflags 2.13.2", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-misc" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e9567599ef23e09b8dad6e429e5738d4509dfc46b3b21f32841a304d16b29c8" +dependencies = [ + "bitflags 2.13.2", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + [[package]] name = "wayland-protocols-plasma" version = "0.3.12" @@ -6473,6 +7404,35 @@ version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" +[[package]] +name = "wgpu" +version = "27.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfe68bac7cde125de7a731c3400723cadaaf1703795ad3f4805f187459cd7a77" +dependencies = [ + "arrayvec", + "bitflags 2.13.2", + "cfg-if", + "cfg_aliases", + "document-features", + "hashbrown 0.16.1", + "js-sys", + "log", + "naga 27.0.3", + "parking_lot", + "portable-atomic", + "profiling", + "raw-window-handle", + "smallvec", + "static_assertions", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "wgpu-core 27.0.3", + "wgpu-hal 27.0.4", + "wgpu-types 27.0.1", +] + [[package]] name = "wgpu" version = "30.0.1" @@ -6492,9 +7452,41 @@ dependencies = [ "raw-window-handle", "smallvec", "static_assertions", - "wgpu-core", - "wgpu-hal", - "wgpu-types", + "wgpu-core 30.0.1", + "wgpu-hal 30.0.1", + "wgpu-types 30.0.1", +] + +[[package]] +name = "wgpu-core" +version = "27.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27a75de515543b1897b26119f93731b385a19aea165a1ec5f0e3acecc229cae7" +dependencies = [ + "arrayvec", + "bit-set 0.8.0", + "bit-vec 0.8.0", + "bitflags 2.13.2", + "bytemuck", + "cfg_aliases", + "document-features", + "hashbrown 0.16.1", + "indexmap", + "log", + "naga 27.0.3", + "once_cell", + "parking_lot", + "portable-atomic", + "profiling", + "raw-window-handle", + "rustc-hash 1.1.0", + "smallvec", + "thiserror 2.0.21", + "wgpu-core-deps-apple 27.0.0", + "wgpu-core-deps-emscripten", + "wgpu-core-deps-windows-linux-android 27.0.0", + "wgpu-hal 27.0.4", + "wgpu-types 27.0.1", ] [[package]] @@ -6504,8 +7496,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "14c018fce9b6270aa203c2fdd56f3cce996713534bd757e4ea58c8560b121f14" dependencies = [ "arrayvec", - "bit-set", - "bit-vec", + "bit-set 0.10.0", + "bit-vec 0.9.1", "bitflags 2.13.2", "bytemuck", "cfg_aliases", @@ -6513,7 +7505,7 @@ dependencies = [ "hashbrown 0.17.1", "indexmap", "log", - "naga", + "naga 30.0.1", "naga-types", "once_cell", "parking_lot", @@ -6523,11 +7515,20 @@ dependencies = [ "rustc-hash 1.1.0", "smallvec", "thiserror 2.0.21", - "wgpu-core-deps-apple", - "wgpu-core-deps-windows-linux-android", - "wgpu-hal", + "wgpu-core-deps-apple 30.0.1", + "wgpu-core-deps-windows-linux-android 30.0.1", + "wgpu-hal 30.0.1", "wgpu-naga-bridge", - "wgpu-types", + "wgpu-types 30.0.1", +] + +[[package]] +name = "wgpu-core-deps-apple" +version = "27.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0772ae958e9be0c729561d5e3fd9a19679bcdfb945b8b1a1969d9bfe8056d233" +dependencies = [ + "wgpu-hal 27.0.4", ] [[package]] @@ -6536,7 +7537,25 @@ version = "30.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "061f3d319a40d39d00b1ecc2c33b89fe21d4e6fe01859df3500a3a8ecccd6b68" dependencies = [ - "wgpu-hal", + "wgpu-hal 30.0.1", +] + +[[package]] +name = "wgpu-core-deps-emscripten" +version = "27.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b06ac3444a95b0813ecfd81ddb2774b66220b264b3e2031152a4a29fda4da6b5" +dependencies = [ + "wgpu-hal 27.0.4", +] + +[[package]] +name = "wgpu-core-deps-windows-linux-android" +version = "27.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71197027d61a71748e4120f05a9242b2ad142e3c01f8c1b47707945a879a03c3" +dependencies = [ + "wgpu-hal 27.0.4", ] [[package]] @@ -6545,7 +7564,56 @@ version = "30.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7586165fd5f6d881cb9ce4bb71f40d6caab2c0f1837e3fc1d9788a197fb6004f" dependencies = [ - "wgpu-hal", + "wgpu-hal 30.0.1", +] + +[[package]] +name = "wgpu-hal" +version = "27.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b21cb61c57ee198bc4aff71aeadff4cbb80b927beb912506af9c780d64313ce" +dependencies = [ + "android_system_properties", + "arrayvec", + "ash", + "bit-set 0.8.0", + "bitflags 2.13.2", + "block", + "bytemuck", + "cfg-if", + "cfg_aliases", + "core-graphics-types 0.2.0", + "glow 0.16.0", + "glutin_wgl_sys", + "gpu-alloc", + "gpu-allocator 0.27.0", + "gpu-descriptor", + "hashbrown 0.16.1", + "js-sys", + "khronos-egl", + "libc", + "libloading", + "log", + "metal", + "naga 27.0.3", + "ndk-sys", + "objc", + "once_cell", + "ordered-float", + "parking_lot", + "portable-atomic", + "portable-atomic-util", + "profiling", + "range-alloc", + "raw-window-handle", + "renderdoc-sys", + "smallvec", + "thiserror 2.0.21", + "wasm-bindgen", + "web-sys", + "wgpu-types 27.0.1", + "windows 0.58.0", + "windows-core 0.58.0", ] [[package]] @@ -6557,18 +7625,18 @@ dependencies = [ "android_system_properties", "arrayvec", "ash", - "bit-set", + "bit-set 0.10.0", "bitflags 2.13.2", "block2 0.6.2", "bytemuck", "cfg-if", "cfg_aliases", - "gpu-allocator", + "gpu-allocator 0.28.0", "hashbrown 0.17.1", "libc", "libloading", "log", - "naga", + "naga 30.0.1", "naga-types", "objc2 0.6.5", "objc2-core-foundation", @@ -6590,9 +7658,9 @@ dependencies = [ "static_assertions", "thiserror 2.0.21", "wgpu-naga-bridge", - "wgpu-types", - "windows", - "windows-core", + "wgpu-types 30.0.1", + "windows 0.62.2", + "windows-core 0.62.2", ] [[package]] @@ -6601,8 +7669,22 @@ version = "30.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d2f62e73117bb7a62bfd9c5a5841438a823f6566c6442a808ee269d2d055c081" dependencies = [ - "naga", - "wgpu-types", + "naga 30.0.1", + "wgpu-types 30.0.1", +] + +[[package]] +name = "wgpu-types" +version = "27.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "afdcf84c395990db737f2dd91628706cb31e86d72e53482320d368e52b5da5eb" +dependencies = [ + "bitflags 2.13.2", + "bytemuck", + "js-sys", + "log", + "thiserror 2.0.21", + "web-sys", ] [[package]] @@ -6628,6 +7710,30 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "window_clipboard" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5654226305eaf2dde8853fb482861d28e5dcecbbd40cb88e8393d94bb80d733" +dependencies = [ + "clipboard-win", + "clipboard_macos", + "clipboard_wayland", + "clipboard_x11", + "raw-window-handle", + "thiserror 2.0.21", +] + +[[package]] +name = "windows" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd04d41d93c4992d421894c18c8b43496aa748dd4c081bac0dc93eb0489272b6" +dependencies = [ + "windows-core 0.58.0", + "windows-targets 0.52.6", +] + [[package]] name = "windows" version = "0.62.2" @@ -6635,7 +7741,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" dependencies = [ "windows-collections", - "windows-core", + "windows-core 0.62.2", "windows-future", "windows-numerics", ] @@ -6646,7 +7752,20 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" dependencies = [ - "windows-core", + "windows-core 0.62.2", +] + +[[package]] +name = "windows-core" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba6d44ec8c2591c134257ce647b7ea6b20335bf6379a27dac5f1641fcf59f99" +dependencies = [ + "windows-implement 0.58.0", + "windows-interface 0.58.0", + "windows-result 0.2.0", + "windows-strings 0.1.0", + "windows-targets 0.52.6", ] [[package]] @@ -6655,11 +7774,11 @@ version = "0.62.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" dependencies = [ - "windows-implement", - "windows-interface", + "windows-implement 0.60.2", + "windows-interface 0.59.3", "windows-link", - "windows-result", - "windows-strings", + "windows-result 0.4.1", + "windows-strings 0.5.1", ] [[package]] @@ -6668,11 +7787,22 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" dependencies = [ - "windows-core", + "windows-core 0.62.2", "windows-link", "windows-threading", ] +[[package]] +name = "windows-implement" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "windows-implement" version = "0.60.2" @@ -6684,6 +7814,17 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "windows-interface" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "windows-interface" version = "0.59.3" @@ -6707,7 +7848,7 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" dependencies = [ - "windows-core", + "windows-core 0.62.2", "windows-link", ] @@ -6718,8 +7859,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" dependencies = [ "windows-link", - "windows-result", - "windows-strings", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + +[[package]] +name = "windows-result" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e" +dependencies = [ + "windows-targets 0.52.6", ] [[package]] @@ -6731,6 +7881,16 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-strings" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10" +dependencies = [ + "windows-result 0.2.0", + "windows-targets 0.52.6", +] + [[package]] name = "windows-strings" version = "0.5.1" @@ -6940,7 +8100,7 @@ dependencies = [ "redox_syscall 0.4.1", "rustix 0.38.44", "sctk-adwaita", - "smithay-client-toolkit", + "smithay-client-toolkit 0.19.2", "smol_str 0.2.2", "tracing", "unicode-segmentation", @@ -6997,11 +8157,11 @@ version = "0.50.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cae90d1d9f6d1d36ef116b0b1e651a6d7c12ba925439d4d06ff9adc94662a3d8" dependencies = [ - "font-types", + "font-types 0.12.7", "indexmap", - "kurbo", + "kurbo 0.13.1", "log", - "read-fonts", + "read-fonts 0.41.0", ] [[package]] @@ -7362,19 +8522,34 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" +[[package]] +name = "zune-core" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f423a2c17029964870cfaabb1f13dfab7d092a62a29a89264f4d36990ca414a" + [[package]] name = "zune-core" version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d56377fd46368984a170bc5aac5567e52ca5da874caa60bea39fcbca78fb658b" +[[package]] +name = "zune-jpeg" +version = "0.4.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29ce2c8a9384ad323cf564b67da86e21d3cfdff87908bc1223ed5c99bc792713" +dependencies = [ + "zune-core 0.4.12", +] + [[package]] name = "zune-jpeg" version = "0.5.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" dependencies = [ - "zune-core", + "zune-core 0.5.3", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 8f2ad0f..7562c83 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,6 +6,9 @@ members = [ "crates/app", "crates/cli", "crates/design", + # P4(D26 iced 重写)第一步:与 Slint 版 `crates/app` 并存的新 UI crate。 + # bin 名 `unlucky-launcher-iced`,翻牌时才改名(见 DevDocs/SubAgentsTalk/app-iced-骨架与翻牌.md)。 + "crates/app-iced", ] [workspace.package] diff --git a/crates/app-iced/Cargo.toml b/crates/app-iced/Cargo.toml new file mode 100644 index 0000000..5cd25da --- /dev/null +++ b/crates/app-iced/Cargo.toml @@ -0,0 +1,51 @@ +[package] +name = "unlucky-app-iced" +description = "Unlucky-Launcher 桌面壳(iced 0.14 重写版):M3 Expressive 骨架 / 令牌适配 / 截图通道" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +repository.workspace = true +rust-version.workspace = true +publish.workspace = true + +# 并存期(D26):Slint 版仍是 `crates/app` 的 `unlucky-launcher`,本 crate 出 +# `unlucky-launcher-iced`。两个 package 在同一 target 目录产出同名 bin 会冲突, +# 所以**翻牌前不许改回 `unlucky-launcher`**(步骤见 SubAgentsTalk/app-iced-骨架与翻牌.md)。 +[[bin]] +name = "unlucky-launcher-iced" +path = "src/main.rs" + +[lib] +name = "unlucky_app_iced" +path = "src/lib.rs" + +[dependencies] +# 令牌唯一真源(M61):颜色/排版/形状/间距/图标几何全部只从这里取。 +unlucky-design = { path = "../design", version = "0.1" } + +# UI 框架(D26 锁定 iced 0.14)。**不得依赖 slint**。 +# 关掉默认 features 后必须显式给执行器(M60 踩坑 Q2:否则 `No futures executor has been enabled`), +# 这里给 `thread-pool`(iced 自己的默认执行器,不额外引 tokio 运行时)。 +# `wgpu` 由本 crate 的 `gpu` feature 打开,于是「仅 CPU」体积基线用 `--no-default-features` 就能量到。 +iced = { version = "=0.14.0", default-features = false, features = [ + "tiny-skia", + "crisp", + "advanced", + "svg", + "thread-pool", + "x11", + "wayland", + "linux-theme-detection", +] } + +# 截图落 PNG。只开 png 编码器,不要默认全套 codec(体积)。 +image = { version = "0.25", default-features = false, features = ["png"] } + +[features] +default = ["gpu"] +# 硬件渲染后端(wgpu→Vulkan/DX12)。关闭后只剩 tiny-skia(CPU),用于体积基线与无 GPU 机器。 +gpu = ["iced/wgpu"] + +[lints] +workspace = true diff --git a/crates/app-iced/src/app.rs b/crates/app-iced/src/app.rs new file mode 100644 index 0000000..2ded81f --- /dev/null +++ b/crates/app-iced/src/app.rs @@ -0,0 +1,400 @@ +//! Elm 三件套:`State` / `Message` / `update` + `view` 接线。 +//! +//! ## 启动序列(顺序有讲究) +//! +//! 1. **探测渲染后端** → 写 `ICED_BACKEND`(必须在 `run()` 之前,M60 Q1); +//! 2. **求值字体栈**(D28 前半)→ 一行启动日志; +//! 3. `iced::application(..).run()`:窗口 1180×760、无边框(自绘标题栏); +//! 4. 首个 `view()` 打「到首帧」耗时;截图模式下第 3 帧 `window::screenshot` 落盘后退出。 +//! +//! ## 为什么截图要等 3 帧 +//! +//! 无边框窗口启动时会先收到一次「显示器工作区尺寸」的 `Resized`,再回到设定尺寸(M60 坑 #5), +//! 第一帧可能是旧尺寸;再叠上字体系统首次上屏的排版抖动,留 2 帧余量最稳。 + +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::OnceLock; +use std::time::Instant; + +use iced::{window, window::Direction, Element, Subscription, Task}; + +use unlucky_design::color::SchemeFlavor; + +use crate::cli::Options; +use crate::fonts; +use crate::probe; +use crate::shell::{self, NavItem}; +use crate::shot; +use crate::theme::{Families, Ui}; + +/// 验收窗口尺寸(1180×760,100% DPI —— D30 的硬要求)。 +pub const WINDOW_SIZE: iced::Size = iced::Size { + width: 1180.0, + height: 760.0, +}; + +/// 产品名(自绘标题栏的标题;也是 `'static`,好让 view 借用)。 +pub const APP_TITLE: &str = "Unlucky Launcher"; + +/// 窗口最小尺寸(低于这个值骨架会挤坏:88 导航 + 页面留白 + 卡片)。 +const MIN_SIZE: iced::Size = iced::Size { + width: 880.0, + height: 560.0, +}; + +/// 截图落盘的帧号(见模块文档)。 +const CAPTURE_AT_FRAME: u32 = 3; + +/// 进程内「run() 开始」的时刻(首帧耗时基线)。 +static STARTED: OnceLock = OnceLock::new(); +/// 首帧日志只打一次。 +static FIRST_FRAME_LOGGED: AtomicBool = AtomicBool::new(false); + +/// 应用状态(**只有 UI 状态**;业务状态等页面 mission 接 `host`/`state` 时再进)。 +pub struct App { + options: Options, + families: Families, + ui: Ui, + nav: NavItem, + window: Option, + frames: u32, + /// 截图模式的「主题已定」闸门:`system::theme()` 是异步的,早拍会拍到错的深浅色。 + theme_resolved: bool, +} + +/// 消息(单一大枚举;页面接进来时按页拆子枚举)。 +#[derive(Debug, Clone)] +pub enum Message { + /// 启动即发一次(`iced::application` 的 boot 闭包)。 + Boot, + /// `window::latest()` 的结果。 + WindowReady(Option), + /// 系统主题探测结果。 + SystemTheme(iced::theme::Mode), + /// 一帧画完(只有截图模式才订阅)。 + Frame, + /// 导航项切换。 + Select(NavItem), + /// 按下标题栏 → 拖窗。 + DragWindow, + /// 按下边缘热区 → 缩放窗口。 + ResizeStart(Direction), + /// 最小化。 + Minimize, + /// 最大化/还原。 + ToggleMaximize, + /// 关闭。 + Close, + /// 截图回来了。 + Captured(window::Screenshot), +} + +impl App { + fn new(options: Options, families: Families, flavor: SchemeFlavor) -> Self { + let options_theme_given = options.shot_flavor().is_some(); + Self { + ui: Ui::new(flavor, families), + families, + options, + nav: NavItem::Instances, + window: None, + frames: 0, + // 显式给了主题就算已定;否则等 `SystemTheme` 到达 + theme_resolved: options_theme_given, + } + } + + fn has_screenshot(&self) -> bool { + self.options.screenshot.is_some() + } + + fn update(&mut self, message: Message) -> Task { + match message { + Message::Boot => { + let mut tasks = vec![window::latest().map(Message::WindowReady)]; + + // 主题:参数里没指定就探系统(深浅两套都要能出图) + if self.options.shot_flavor().is_none() { + tasks.push(iced::system::theme().map(Message::SystemTheme)); + } + + Task::batch(tasks) + } + Message::WindowReady(id) => { + self.window = id; + Task::none() + } + Message::SystemTheme(mode) => { + let flavor = match mode { + iced::theme::Mode::Dark => SchemeFlavor::Dark, + iced::theme::Mode::None | iced::theme::Mode::Light => SchemeFlavor::Light, + }; + eprintln!("[theme] 系统主题 = {mode:?} -> {}", slug(flavor)); + self.set_flavor(flavor); + self.theme_resolved = true; + Task::none() + } + Message::Frame => self.on_frame(), + Message::Select(nav) => { + self.nav = nav; + Task::none() + } + Message::DragWindow => match self.window { + Some(id) => window::drag(id), + None => Task::none(), + }, + Message::ResizeStart(direction) => match self.window { + Some(id) => window::drag_resize(id, direction), + None => Task::none(), + }, + Message::Minimize => match self.window { + Some(id) => window::minimize(id, true), + None => Task::none(), + }, + Message::ToggleMaximize => match self.window { + Some(id) => window::toggle_maximize(id), + None => Task::none(), + }, + Message::Close => iced::exit(), + Message::Captured(screenshot) => self.on_captured(&screenshot), + } + } + + /// 切主题:只重建 [`Ui`](= 重建 Style),**不动任何 UI 状态**。 + fn set_flavor(&mut self, flavor: SchemeFlavor) { + if self.ui.flavor() == flavor { + return; + } + + let next = Ui::new(flavor, self.families); + eprintln!( + "[theme] {} -> {} (Theme::name 变化触发缓存重算)", + self.ui.name(), + next.name() + ); + self.ui = next; + } + fn on_frame(&mut self) -> Task { + self.frames += 1; + + // 主题没定就不出图:等 `SystemTheme`(否则暗色系统上会拍到浅色图) + if !self.has_screenshot() || !self.theme_resolved || self.frames < CAPTURE_AT_FRAME { + return Task::none(); + } + + match self.window { + Some(id) => window::screenshot(id).map(Message::Captured), + None => Task::none(), + } + } + + fn on_captured(&mut self, screenshot: &window::Screenshot) -> Task { + let Some(path) = self.options.screenshot.clone() else { + return Task::none(); + }; + + match shot::save(screenshot, &path) { + Ok(summary) => { + eprintln!("{}", summary.describe()); + eprintln!( + "[shot] theme={} renderer={}", + shot::flavor_slug(&self.ui), + renderer_note(self.options.backend) + ); + } + Err(error) => eprintln!("[shot] 失败:{error}"), + } + + iced::exit() + } + + fn subscription(&self) -> Subscription { + // 只有截图模式才订阅帧事件:否则「每帧一条消息 → 每帧重绘」会白烧 CPU。 + if self.wants_frames() { + window::frames().map(|_| Message::Frame) + } else { + Subscription::none() + } + } + + /// 是否订阅帧事件(= 截图模式)。 + fn wants_frames(&self) -> bool { + self.has_screenshot() + } + + fn theme(&self) -> iced::Theme { + self.ui.theme() + } + + fn title(&self) -> String { + APP_TITLE.to_owned() + } + + fn view(&self) -> Element<'_, Message> { + if !FIRST_FRAME_LOGGED.swap(true, Ordering::Relaxed) { + if let Some(started) = STARTED.get() { + eprintln!( + "[boot] first frame in {:.0} ms(run() 开始 → 首次 view,含后端探测与字体扫描)", + started.elapsed().as_secs_f64() * 1000.0 + ); + } + } + + shell::view( + &self.ui, + self.nav, + APP_TITLE, + self.nav.label(), + "Skeleton only: titlebar, navigation rail and one card. \ + Business pages, the component library and the glass shader are follow-up missions.", + self.options.force_state, + ) + } +} + +fn renderer_note(backend: crate::Backend) -> &'static str { + if cfg!(feature = "gpu") { + match backend { + crate::Backend::Cpu => "cpu(forced)", + crate::Backend::Gpu => "gpu(preferred)", + crate::Backend::Auto => "auto", + } + } else { + "cpu(build without wgpu)" + } +} + +/// 主题名(日志用,和 `Ui::name()` 同一口径)。 +fn slug(flavor: SchemeFlavor) -> &'static str { + if flavor.is_dark() { + "ull-m3-dark" + } else { + "ull-m3-light" + } +} + +/// 启动 iced(阻塞到退出)。`--diff` 不走这里,见 `main.rs`。 +pub(crate) fn run(options: Options) -> iced::Result { + let _ = STARTED.set(Instant::now()); + + let probe_started = Instant::now(); + // 只有 `auto` 需要探测:`cpu` 与 `gpu` 的决策与「有没有硬件 GPU」无关, + // 而枚举 13 个适配器在本机要 0.65~1.2 s(实测),纯属白花。 + let needs_probe = options.backend == crate::Backend::Auto; + let probe = if needs_probe { + probe::Probe::run() + } else { + probe::Probe::default() + }; + let probe_ms = probe_started.elapsed().as_secs_f64() * 1000.0; + probe::apply(options.backend, &probe); + if cfg!(feature = "gpu") { + if needs_probe { + eprintln!("[probe] 探测耗时 {probe_ms:.0} ms(wgpu 实例 + 全后端适配器枚举)"); + } else { + eprintln!( + "[probe] 探测耗时 {probe_ms:.0} ms(--renderer={} 与硬件无关,跳过枚举)", + options.backend.name() + ); + } + } + + let font_started = Instant::now(); + let resolved = fonts::resolve(); + eprintln!( + "[font] 求值耗时 {:.0} ms", + font_started.elapsed().as_secs_f64() * 1000.0 + ); + eprintln!("{}", resolved.describe()); + if resolved.has_synthetic() { + eprintln!( + "[font] 警告:有字重落在「合成加粗」档(观感降级),D28 后半(CDN 字体/按档换族)要处理" + ); + } + let families = resolved.families(); + + // 截图模式:`--screenshot-theme` > `--theme` > 浅色(后续系统探测会覆盖) + let flavor = options.shot_flavor().unwrap_or(SchemeFlavor::Light); + let boot_options = options.clone(); + + let application = iced::application( + move || { + ( + App::new(boot_options.clone(), families, flavor), + Task::done(Message::Boot), + ) + }, + App::update, + App::view, + ) + .title(App::title) + .theme(App::theme) + .subscription(App::subscription) + .default_font(iced::Font { + family: iced::font::Family::Name(families.text), + ..iced::Font::DEFAULT + }) + .window(window::Settings { + size: WINDOW_SIZE, + min_size: Some(MIN_SIZE), + decorations: false, + resizable: true, + ..window::Settings::default() + }); + + match options.scale_factor { + Some(scale) => application.scale_factor(move |_| scale).run(), + None => application.run(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const FAMILIES: Families = Families { + text: "Segoe UI", + display: "Segoe UI", + mono: "Consolas", + }; + + #[test] + fn switching_flavor_rebuilds_styles_but_keeps_ui_state() { + let mut app = App::new(Options::default(), FAMILIES, SchemeFlavor::Light); + app.nav = NavItem::Logs; + assert_eq!(app.ui.name(), "ull-m3-light"); + + app.set_flavor(SchemeFlavor::Dark); + + assert_eq!(app.ui.name(), "ull-m3-dark", "切主题要换主题名"); + assert_eq!(app.nav, NavItem::Logs, "切主题不许重置 UI 状态"); + assert!(app.options.screenshot.is_none(), "切主题不许改启动参数"); + } + + #[test] + fn window_size_is_the_acceptance_size() { + assert_eq!((WINDOW_SIZE.width, WINDOW_SIZE.height), (1180.0, 760.0)); + const { + assert!(MIN_SIZE.width < WINDOW_SIZE.width); + assert!(MIN_SIZE.height < WINDOW_SIZE.height); + } + } + + #[test] + fn slug_matches_the_theme_name() { + assert_eq!(slug(SchemeFlavor::Light), "ull-m3-light"); + assert_eq!(slug(SchemeFlavor::Dark), "ull-m3-dark"); + } + + #[test] + fn frames_are_only_requested_in_screenshot_mode() { + let mut app = App::new(Options::default(), FAMILIES, SchemeFlavor::Light); + assert!(!app.wants_frames(), "普通运行不订阅帧,避免每帧重绘"); + + app.options.screenshot = Some(std::path::PathBuf::from("x.png")); + assert!( + app.wants_frames(), + "截图模式必须订阅帧事件才能在第 3 帧出图" + ); + } +} diff --git a/crates/app-iced/src/cli.rs b/crates/app-iced/src/cli.rs new file mode 100644 index 0000000..8def288 --- /dev/null +++ b/crates/app-iced/src/cli.rs @@ -0,0 +1,298 @@ +//! 命令行参数(只做骨架期需要的那些;业务开关等页面 mission 再加)。 +//! +//! 取值语法与现役 Slint 版一致:`--key value` 与 `--key=value` 两种都认 +//! (`crates/app/src/screenshot.rs` 的 `value_of` 是同一口径),这样将来 CI 换 bin 不用改脚本。 + +use std::path::PathBuf; + +use unlucky_design::color::SchemeFlavor; + +/// 渲染后端请求(D27)。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum Backend { + /// 探测后决定:有硬件 GPU 就让 iced 自己走 wgpu→tiny-skia 回退;没有就强制 CPU 起步。 + #[default] + Auto, + /// 优先 GPU(写 `ICED_BACKEND=wgpu,tiny-skia`:拿不到适配器仍会降级,不硬失败)。 + Gpu, + /// 强制 CPU(`--software-renderer` 的等价物,写 `ICED_BACKEND=tiny-skia`)。 + Cpu, +} + +impl Backend { + /// 稳定名字(日志用)。 + pub const fn name(self) -> &'static str { + match self { + Backend::Auto => "auto", + Backend::Gpu => "gpu", + Backend::Cpu => "cpu", + } + } +} + +/// 解析后的启动参数。 +#[derive(Debug, Clone, PartialEq)] +pub struct Options { + /// 渲染后端请求。 + pub backend: Backend, + /// 主题:`None` = 探测系统主题。 + pub theme: Option, + /// 截图通道:给了路径就出图后退出。 + pub screenshot: Option, + /// 截图时用的主题(`--screenshot-theme`,优先级高于 `--theme`)。 + pub screenshot_theme: Option, + /// 强制窗口缩放因子(截图验收要 100% DPI;`None` = 用系统 DPI)。 + pub scale_factor: Option, + /// `--diff `:只做两图逐像素对比,不开窗。 + pub diff: Option<(PathBuf, PathBuf)>, + /// `--diff-scan `:对比时额外做 ±n px 的平移扫描(找最佳对齐,默认 0 = 不扫)。 + pub diff_scan: u32, + /// `--force-state=hover|pressed`:把按钮强制成某个状态便于截图取证(D30 的状态图)。 + pub force_state: Option, + /// `--data-dir `:当前**接受并忽略**。 + /// + /// 现役 Slint 版的 CI 冒烟用 `--software-renderer --screenshot --data-dir `, + /// 翻牌后同一套脚本要能直接跑 iced 版;真正的数据目录消费由后续页面 mission 接 `host`/`state` 时做。 + pub data_dir: Option, +} + +/// 截图取证用的强制交互状态(只影响观感,不改变任何状态机)。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ForcedState { + /// 所有可交互控件按 hover 画。 + Hover, + /// 所有可交互控件按 pressed 画。 + Pressed, +} + +impl ForcedState { + /// 稳定名字(日志用)。 + pub const fn name(self) -> &'static str { + match self { + ForcedState::Hover => "hover", + ForcedState::Pressed => "pressed", + } + } +} + +impl Default for Options { + fn default() -> Self { + Self { + backend: Backend::Auto, + theme: None, + screenshot: None, + screenshot_theme: None, + scale_factor: None, + diff: None, + diff_scan: 0, + force_state: None, + data_dir: None, + } + } +} + +impl Options { + /// 从进程参数解析。 + /// + /// # Errors + /// 参数值不合法(未知的 `--renderer` / `--theme` / 非法 `--scale-factor`)时返回人话。 + pub fn from_env() -> Result { + Self::parse(std::env::args().skip(1)) + } + + /// 从任意参数序列解析(`argv[1..]`,测试直接喂数组)。 + /// + /// # Errors + /// 同 [`Options::from_env`]。 + pub fn parse(args: impl IntoIterator) -> Result { + let mut options = Self::default(); + let mut args = args.into_iter(); + + while let Some(arg) = args.next() { + let (key, inline) = match arg.split_once('=') { + Some((key, value)) => (key.to_owned(), Some(value.to_owned())), + None => (arg.clone(), None), + }; + + match key.as_str() { + "--software-renderer" => options.backend = Backend::Cpu, + "--renderer" => { + let value = take(&mut args, inline, "--renderer")?; + options.backend = match value.as_str() { + "auto" => Backend::Auto, + "gpu" => Backend::Gpu, + "cpu" => Backend::Cpu, + other => { + return Err(format!("--renderer 只认 auto|gpu|cpu,收到 {other:?}")); + } + }; + } + "--theme" => { + options.theme = Some(flavor(&take(&mut args, inline, "--theme")?)?); + } + "--screenshot-theme" => { + options.screenshot_theme = + Some(flavor(&take(&mut args, inline, "--screenshot-theme")?)?); + } + "--screenshot" => { + options.screenshot = + Some(PathBuf::from(take(&mut args, inline, "--screenshot")?)); + } + "--scale-factor" => { + let raw = take(&mut args, inline, "--scale-factor")?; + let value: f32 = raw + .parse() + .map_err(|_| format!("--scale-factor 需要数字,收到 {raw:?}"))?; + if !(value.is_finite() && value > 0.0) { + return Err(format!("--scale-factor 必须 > 0,收到 {raw:?}")); + } + options.scale_factor = Some(value); + } + "--diff" => { + let left = take(&mut args, inline, "--diff")?; + let right = args + .next() + .ok_or_else(|| "--diff 需要两张图的路径".to_owned())?; + options.diff = Some((PathBuf::from(left), PathBuf::from(right))); + } + "--diff-scan" => { + let raw = take(&mut args, inline, "--diff-scan")?; + options.diff_scan = raw + .parse() + .map_err(|_| format!("--diff-scan 需要非负整数,收到 {raw:?}"))?; + } + "--force-state" => { + let raw = take(&mut args, inline, "--force-state")?; + options.force_state = Some(match raw.as_str() { + "hover" => ForcedState::Hover, + "pressed" => ForcedState::Pressed, + other => { + return Err(format!( + "--force-state 只认 hover|pressed,收到 {other:?}" + )); + } + }); + } + // 翻牌前的兼容参数:接受并忽略(见字段文档) + "--data-dir" => { + options.data_dir = Some(PathBuf::from(take(&mut args, inline, "--data-dir")?)); + } + other => return Err(format!("未知参数 {other:?}")), + } + } + + Ok(options) + } + + /// 截图时实际使用的主题(截图专用参数优先)。 + pub fn shot_flavor(&self) -> Option { + self.screenshot_theme.or(self.theme) + } +} + +fn take( + args: &mut impl Iterator, + inline: Option, + flag: &str, +) -> Result { + inline + .or_else(|| args.next()) + .ok_or_else(|| format!("{flag} 需要一个值")) +} + +fn flavor(raw: &str) -> Result { + match raw { + "light" => Ok(SchemeFlavor::Light), + "dark" => Ok(SchemeFlavor::Dark), + other => Err(format!("--theme 只认 light|dark,收到 {other:?}")), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn parse(args: &[&str]) -> Result { + Options::parse(args.iter().map(|arg| (*arg).to_owned())) + } + + #[test] + fn defaults_are_auto_and_system_theme() { + let options = parse(&[]).expect("空参数要能解析"); + assert_eq!(options.backend, Backend::Auto); + assert_eq!(options.theme, None); + assert_eq!(options.screenshot, None); + } + + #[test] + fn software_renderer_is_an_alias_of_cpu() { + assert_eq!( + parse(&["--software-renderer"]).unwrap().backend, + Backend::Cpu + ); + assert_eq!(parse(&["--renderer=cpu"]).unwrap().backend, Backend::Cpu); + assert_eq!(parse(&["--renderer", "gpu"]).unwrap().backend, Backend::Gpu); + } + + #[test] + fn screenshot_accepts_both_argv_forms() { + let spaced = parse(&["--screenshot", "out.png"]).unwrap(); + let inline = parse(&["--screenshot=out.png"]).unwrap(); + assert_eq!(spaced.screenshot, Some(PathBuf::from("out.png"))); + assert_eq!(spaced.screenshot, inline.screenshot); + } + + #[test] + fn screenshot_theme_wins_over_theme() { + let options = parse(&["--theme=dark", "--screenshot-theme=light"]).unwrap(); + assert_eq!(options.theme, Some(SchemeFlavor::Dark)); + assert_eq!(options.shot_flavor(), Some(SchemeFlavor::Light)); + } + + #[test] + fn bad_values_are_reported_not_panicked() { + assert!(parse(&["--renderer=metal"]).is_err()); + assert!(parse(&["--theme=blue"]).is_err()); + assert!(parse(&["--scale-factor=0"]).is_err()); + assert!(parse(&["--scale-factor=abc"]).is_err()); + assert!(parse(&["--screenshot"]).is_err()); + assert!(parse(&["--diff", "only-one.png"]).is_err()); + assert!(parse(&["--nope"]).is_err()); + } + + #[test] + fn data_dir_is_accepted_and_ignored_for_the_flip() { + // 现役 CI 冒烟:--software-renderer --screenshot --data-dir + let options = parse(&[ + "--software-renderer", + "--screenshot", + "shot.png", + "--data-dir", + "/d/tmp/x", + ]) + .expect("翻牌前必须兼容 --data-dir"); + assert_eq!(options.backend, Backend::Cpu); + assert_eq!(options.screenshot, Some(PathBuf::from("shot.png"))); + assert_eq!(options.data_dir, Some(PathBuf::from("/d/tmp/x"))); + } + + #[test] + fn diff_scan_and_force_state_parse() { + assert_eq!(parse(&["--diff-scan=2"]).unwrap().diff_scan, 2); + assert_eq!( + parse(&["--force-state", "hover"]).unwrap().force_state, + Some(ForcedState::Hover) + ); + assert!(parse(&["--force-state=focus"]).is_err()); + assert!(parse(&["--diff-scan=-1"]).is_err()); + } + + #[test] + fn diff_takes_two_paths() { + let options = parse(&["--diff", "a.png", "b.png"]).unwrap(); + assert_eq!( + options.diff, + Some((PathBuf::from("a.png"), PathBuf::from("b.png"))) + ); + } +} diff --git a/crates/app-iced/src/fonts.rs b/crates/app-iced/src/fonts.rs new file mode 100644 index 0000000..c2c6c7c --- /dev/null +++ b/crates/app-iced/src/fonts.rs @@ -0,0 +1,263 @@ +//! 字体基座(D28 前半):把令牌的字体栈求值成「本机真的有的族名」,并打一行启动日志。 +//! +//! ## 做什么 +//! +//! 1. 取 `Platform::current()` 的三条栈(Display / Text / Mono,见 `unlucky_design::typography`); +//! 2. 用 iced 的**全局字体系统**(`iced_graphics::text::font_system()`,即 cosmic-text 的 +//! `FontSystem`,首次调用会扫系统字体)核对每个族名是否装机 → 取栈里第一个装机可用的族; +//! 3. 给出 400/500/700 三档字重的落点(原生 / 最近档 / 合成加粗): +//! **先查系统里这个族真实提供的字面(fontdb 的 `FaceInfo.weight`)**,查不到才回落到令牌的 +//! 纯函数 `Platform::resolve_weight`。缺档按降级记录。 +//! +//! ## 为什么要真查系统 +//! +//! 令牌栈写的是「Win11 有 Segoe UI Variable Text」,但 Win10 / Server 只有 `Segoe UI`(静态 +//! 300/400/600/700,**没有 500**),干净安装的 Linux 可能一个都没有。只按栈头写死族名会把 +//! 「实际使用哪个族、哪一档字重」变成猜测——本 mission 的开发机就是 Windows Server 2025, +//! 实测就回退到了经典 `Segoe UI`(见交接文档的启动日志)。 + +//! ## 不做什么 +//! +//! **中文字体 CDN 下载不在这里**(后续 mission)。本模块只做「系统字体优先」这一半, +//! 也没有能力判断 CJK 字形覆盖(那是 `ttf-parser` 查 cmap 的活,M60 Q3 的 spike 做过)。 + +use unlucky_design::typography::{FontFamily, Platform, Weight, WeightMatch}; + +use crate::theme::Families; + +/// 一条栈的求值结果。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct FamilyReport { + /// 栈的用途(`text` / `display` / `mono`)。 + pub stack: &'static str, + /// 实际选中的族名(栈里第一个装机可用的;都没装就是栈头)。 + pub family: &'static str, + /// 选中的族是否真的在系统字体库里。 + pub installed: bool, +} + +/// 一档字重的求值结果。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct WeightReport { + /// 请求的字重(400/500/700)。 + pub requested: u16, + /// 实际可用的字重(降级后)。 + pub available: u16, + /// 匹配质量(原生 / 最近档 / 合成加粗)。 + pub quality: WeightMatch, +} + +/// 字体求值的全部结论(启动日志与测试都用它)。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Resolved { + /// 平台(决定栈与字重覆盖表)。 + pub platform: Platform, + /// 三条栈的落点。 + pub families: [FamilyReport; 3], + /// 三档字重的落点(对**正文**栈求值)。 + pub weights: [WeightReport; 3], +} + +impl Resolved { + /// 把结论塞回主题适配层要的形状。 + pub fn families(&self) -> Families { + Families { + text: self.families[0].family, + display: self.families[1].family, + mono: self.families[2].family, + } + } + + /// 是否有任何一档走了「合成加粗」(= 观感降级,要记日志)。 + pub fn has_synthetic(&self) -> bool { + self.weights + .iter() + .any(|weight| weight.quality == WeightMatch::Synthetic) + } + + /// 启动日志(两行:一行族,一行字重)。 + pub fn describe(&self) -> String { + let families = self + .families + .iter() + .map(|report| { + format!( + "{}={:?}{}", + report.stack, + report.family, + if report.installed { + "" + } else { + "(未装机→回落)" + } + ) + }) + .collect::>() + .join(" "); + + let weights = self + .weights + .iter() + .map(|report| { + let quality = match report.quality { + WeightMatch::Native => "native", + WeightMatch::Nearest => "nearest", + WeightMatch::Synthetic => "synthetic", + }; + format!("w{}={}({quality})", report.requested, report.available) + }) + .collect::>() + .join(" "); + + format!( + "[font] platform={} {families} | weights {weights}", + self.platform.name() + ) + } +} + +/// 求值本机字体(会触发 iced 全局字体系统的首次初始化,约 0.1~0.8s)。 +pub fn resolve() -> Resolved { + let platform = Platform::current(); + let faces = system_faces(); + + let is_installed = |family: &str| -> bool { + faces + .as_ref() + .is_none_or(|faces: &Vec| faces.iter().any(|face| face.family == family)) + }; + + let families = [ + FamilyReport { + stack: "text", + family: pick(platform, FontFamily::Text, &is_installed), + installed: is_installed(pick(platform, FontFamily::Text, &is_installed)), + }, + FamilyReport { + stack: "display", + family: pick(platform, FontFamily::Display, &is_installed), + installed: is_installed(pick(platform, FontFamily::Display, &is_installed)), + }, + FamilyReport { + stack: "mono", + family: pick(platform, FontFamily::Mono, &is_installed), + installed: is_installed(pick(platform, FontFamily::Mono, &is_installed)), + }, + ]; + + // 字重:**优先用系统里这个族真实提供的档位**(令牌表的 `WeightCoverage` 是「这个族通常有哪几档」, + // 装机版可能是静态子集:Win Server / 精简版只有经典 Segoe UI,没有 500)。查不到才回落到令牌纯函数。 + let weights = [Weight::Regular, Weight::Medium, Weight::Bold] + .map(|weight| weight_report(platform, families[0].family, weight, faces.as_deref())); + + Resolved { + platform, + families, + weights, + } +} + +/// 一条系统字面(族名 + 字重)。 +#[derive(Debug, Clone, PartialEq, Eq)] +struct Face { + family: String, + weight: u16, +} + +/// 某档字重的落点:先问系统,再问令牌。 +fn weight_report( + platform: Platform, + family: &str, + weight: Weight, + faces: Option<&[Face]>, +) -> WeightReport { + let requested = weight.value(); + + let available = faces.and_then(|faces| { + let mut weights = faces + .iter() + .filter(|face| face.family == family) + .map(|face| face.weight) + .collect::>(); + weights.sort_unstable(); + weights.dedup(); + if weights.is_empty() { + return None; + } + + Some( + weights + .iter() + .copied() + .min_by_key(|candidate| candidate.abs_diff(requested)) + .unwrap_or(requested), + ) + }); + + match available { + Some(available) => WeightReport { + requested, + available, + quality: quality_of(requested, available), + }, + None => { + let choice = platform.resolve_weight(FontFamily::Text, weight); + WeightReport { + requested: choice.requested, + available: choice.available, + quality: choice.quality, + } + } + } +} + +/// 与令牌层同一判据:原生 / 最近档(差 <200)/ 合成加粗(差 ≥200)。 +fn quality_of(requested: u16, available: u16) -> WeightMatch { + match available.abs_diff(requested) { + 0 => WeightMatch::Native, + delta if delta >= 200 => WeightMatch::Synthetic, + _ => WeightMatch::Nearest, + } +} + +/// 在栈里选第一个**装机可用**的族(跳过 `sans-serif` 这类通用族名)。 +/// +/// 一个都没装上时返回栈头:让 cosmic-text 自己去回退,比我们猜一个更靠谱。 +pub fn pick( + platform: Platform, + stack: FontFamily, + is_installed: &dyn Fn(&str) -> bool, +) -> &'static str { + let faces = platform.font_stack(stack); + + faces + .iter() + .find(|face| !is_generic(face.family) && is_installed(face.family)) + .map_or(faces[0].family, |face| face.family) +} + +/// 通用族名(CSS 关键字,不是真族名)。 +fn is_generic(family: &str) -> bool { + matches!(family, "sans-serif" | "serif" | "monospace" | "cursive") +} + +/// 系统字体库里的全部字面(`None` = 拿不到,视为「不知道」,全部当装机)。 +fn system_faces() -> Option> { + let lock = iced::advanced::graphics::text::font_system(); + let mut font_system = lock.write().ok()?; + + let mut faces = Vec::new(); + for face in font_system.raw().db().faces() { + for (family, _) in &face.families { + faces.push(Face { + family: family.clone(), + weight: face.weight.0, + }); + } + } + + Some(faces) +} + +#[cfg(test)] +mod tests; diff --git a/crates/app-iced/src/fonts/tests.rs b/crates/app-iced/src/fonts/tests.rs new file mode 100644 index 0000000..079cd27 --- /dev/null +++ b/crates/app-iced/src/fonts/tests.rs @@ -0,0 +1,163 @@ +//! `fonts` 的单元测试(拆出来只为「src 文件 ≤400 行」的门禁)。 + +use super::*; +use unlucky_design::typography::WeightCoverage; + +fn only(installed: &'static [&'static str]) -> impl Fn(&str) -> bool { + move |family| installed.contains(&family) +} + +#[test] +fn windows_stack_falls_back_to_classic_segoe_when_variable_is_absent() { + // Win10 没有 Segoe UI Variable Text + let family = pick( + Platform::Windows, + FontFamily::Text, + &only(&["Segoe UI", "Microsoft YaHei UI"]), + ); + assert_eq!(family, "Segoe UI"); +} + +#[test] +fn windows_stack_prefers_the_variable_face_on_win11() { + let family = pick( + Platform::Windows, + FontFamily::Text, + &only(&["Segoe UI Variable Text", "Segoe UI"]), + ); + assert_eq!(family, "Segoe UI Variable Text"); +} + +#[test] +fn generic_family_names_are_never_reported_as_the_resolved_family() { + // 一个都没装:返回栈头,且栈头不是通用族名 + let family = pick(Platform::Linux, FontFamily::Text, &only(&[])); + assert_eq!(family, "Roboto"); + assert!(!is_generic(family)); +} + +#[test] +fn missing_cjk_fonts_do_not_change_the_latin_pick() { + // 装了苹方但没装 SF:macOS 栈应该选 Helvetica Neue 而不是中文族 + let family = pick( + Platform::MacOs, + FontFamily::Text, + &only(&["PingFang SC", "Helvetica Neue"]), + ); + assert_eq!(family, "Helvetica Neue"); +} + +#[test] +fn weight_reports_use_the_token_degradation_path() { + // 没有系统字面表时回落到令牌纯函数:Windows 栈里 Segoe UI Variable Text 是可变 300..700 + let medium = weight_report( + Platform::Windows, + "Segoe UI Variable Text", + Weight::Medium, + None, + ); + assert_eq!(medium.requested, 500); + assert_eq!(medium.quality, WeightMatch::Native); + + // 手工构造一个「只有 400/700」的族(= 经典 Segoe UI),500 应该落到最近档 + let faces = [ + Face { + family: "Segoe UI".to_owned(), + weight: 400, + }, + Face { + family: "Segoe UI".to_owned(), + weight: 700, + }, + ]; + let medium = weight_report(Platform::Windows, "Segoe UI", Weight::Medium, Some(&faces)); + assert_eq!(medium.available, 400, "400 比 700 更近"); + assert_eq!(medium.quality, WeightMatch::Nearest); + + // 只有 400 却要 700 → 合成加粗 + let only_regular = [Face { + family: "Narrow".to_owned(), + weight: 400, + }]; + let bold = weight_report( + Platform::Windows, + "Narrow", + Weight::Bold, + Some(&only_regular), + ); + assert_eq!(bold.quality, WeightMatch::Synthetic); + + // 原生有 500 就用 500 + let has_medium = [Face { + family: "Noto".to_owned(), + weight: 500, + }]; + assert_eq!( + weight_report(Platform::Linux, "Noto", Weight::Medium, Some(&has_medium)).quality, + WeightMatch::Native + ); + + let narrow = WeightCoverage::Static(&[400]); + assert_eq!(narrow.nearest(700), 400); +} + +#[test] +fn describe_is_one_line_and_mentions_all_three_stacks() { + let resolved = Resolved { + platform: Platform::Windows, + families: [ + FamilyReport { + stack: "text", + family: "Segoe UI", + installed: true, + }, + FamilyReport { + stack: "display", + family: "Segoe UI Variable Display", + installed: false, + }, + FamilyReport { + stack: "mono", + family: "Cascadia Mono", + installed: true, + }, + ], + weights: [ + WeightReport { + requested: 400, + available: 400, + quality: WeightMatch::Native, + }, + WeightReport { + requested: 500, + available: 400, + quality: WeightMatch::Nearest, + }, + WeightReport { + requested: 700, + available: 400, + quality: WeightMatch::Synthetic, + }, + ], + }; + + let line = resolved.describe(); + assert_eq!(line.lines().count(), 1); + assert!(line.contains("text=\"Segoe UI\""), "{line}"); + assert!(line.contains("未装机"), "{line}"); + assert!(line.contains("w700=400(synthetic)"), "{line}"); + assert!(resolved.has_synthetic()); + assert_eq!(resolved.families().text, "Segoe UI"); +} + +#[test] +fn type_roles_pick_the_display_stack_for_big_text() { + use unlucky_design::typography::TypeRole; + + assert_eq!( + TypeRole::TitleLarge.style().family, + FontFamily::Display, + "22px 标题走 Display 栈" + ); + assert_eq!(TypeRole::BodyMedium.style().family, FontFamily::Text); +} diff --git a/crates/app-iced/src/icons.rs b/crates/app-iced/src/icons.rs new file mode 100644 index 0000000..09ced9a --- /dev/null +++ b/crates/app-iced/src/icons.rs @@ -0,0 +1,126 @@ +//! 令牌图标(`unlucky_design::icons` 的 24 网格 SVG path)→ iced `svg` 控件。 +//! +//! ## 为什么走 SVG 而不是自绘 canvas +//! +//! 令牌里存的是 **SVG path 字符串**(含 `A` 弧线命令),自绘要自己写 path parser + 弧线细分, +//! 那是一份将来只有自己在维护的代码;iced 的 `svg` 控件已经带 usvg 解析与按 id 缓存的位图。 +//! 代价是二进制多一份 resvg/usvg(体积基线里会单列出来)。 +//! +//! ## 描边语义(`design-令牌约定.md` §4) +//! +//! 令牌的 `Icon::stroke_width(render_px)` 已经把「视觉 1.5px」折算成 **viewBox 单位** +//! (`1.5 × 24 / render_px`),本模块**不重算也不手抄常数**。 +//! 本骨架所有图标都按 24px 渲染(12b 2.2 标题栏 / 5.5 导航),所以每个图标只有一份位图缓存。 + +use std::sync::OnceLock; + +use iced::widget::svg; +use iced::Color; + +use unlucky_design::icons::{IconKind, IconPaint, IconSize}; + +/// 骨架期图标的统一渲染尺寸(12b:标题栏与导航轨都是 24)。 +pub const RENDER_PX: f32 = IconSize::Toolbar.px(); + +/// 生成一个图标的 SVG 源码(`viewBox` 24×24,颜色用 `currentColor` 交给 iced 的色滤镜)。 +/// +/// 之所以用 `currentColor` 而不是把令牌色写进 SVG:色滤镜(`svg::Style::color`)会按 +/// **alpha 蒙版**重涂 RGB,这样一份 SVG 位图能被浅/深两套主题复用,缓存不会翻倍。 +pub fn source(kind: IconKind) -> String { + let icon = kind.icon(); + let paint = match icon.paint { + IconPaint::Stroke => format!( + "fill=\"none\" stroke=\"currentColor\" stroke-width=\"{}\" \ + stroke-linecap=\"round\" stroke-linejoin=\"round\"", + icon.stroke_width(RENDER_PX) + ), + IconPaint::Fill => "fill=\"currentColor\" stroke=\"none\"".to_owned(), + }; + + format!( + "", + RENDER_PX, icon.path + ) +} + +/// 图标的 SVG 句柄(按 `IconKind` 缓存;句柄 id 是内容哈希,所以 iced 侧也能命中缓存)。 +fn handle(kind: IconKind) -> svg::Handle { + static HANDLES: OnceLock> = OnceLock::new(); + + let handles = HANDLES.get_or_init(|| { + IconKind::ALL + .iter() + .map(|kind| svg::Handle::from_memory(source(*kind).into_bytes())) + .collect() + }); + + // 依赖「枚举下标 == IconKind::ALL 下标」(令牌层用 `GLYPHS[self as usize]` 同一约定)。 + // 这条断言把约定钉在测试与 debug 构建里,`IconKind` 一旦乱序插入就会立刻炸。 + debug_assert_eq!( + IconKind::ALL[kind as usize], + kind, + "IconKind::ALL 必须与枚举声明顺序一致" + ); + + handles[kind as usize].clone() +} + +/// 一个图标控件(尺寸走令牌,颜色走令牌角色)。 +pub fn view<'a, Message: 'a>(kind: IconKind, color: Color) -> iced::Element<'a, Message> { + svg(handle(kind)) + .width(RENDER_PX) + .height(RENDER_PX) + .style(move |_theme, _status| svg::Style { color: Some(color) }) + .into() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn every_icon_produces_wellformed_svg() { + for kind in IconKind::ALL { + let source = source(kind); + assert!(source.starts_with(""), "{} 的 SVG 尾不对", kind.name()); + assert!(source.contains("viewBox=\"0 0 24 24\""), "{}", kind.name()); + assert!( + source.contains(kind.icon().path), + "{} 的 path 没写进去", + kind.name() + ); + assert!(!source.contains('#'), "{} 里出现了色值字面量", kind.name()); + } + } + + #[test] + fn stroke_icons_carry_the_token_stroke_width() { + let stroke = source(IconKind::Settings); + let expected = IconKind::Settings.icon().stroke_width(RENDER_PX); + assert!( + stroke.contains(&format!("stroke-width=\"{expected}\"")), + "{stroke}" + ); + assert!(stroke.contains("stroke-linecap=\"round\"")); + // 24px 渲染时的视觉线宽必须是 1.5(M54 结论) + assert!((expected - 1.5).abs() < f32::EPSILON); + } + + #[test] + fn filled_icons_do_not_get_a_stroke() { + // `more` 是三个填充圆点 + let filled = source(IconKind::More); + assert!(filled.contains("fill=\"currentColor\"")); + assert!(filled.contains("stroke=\"none\"")); + } + + #[test] + fn handles_are_cached_per_kind() { + let first = handle(IconKind::Close); + let second = handle(IconKind::Close); + assert_eq!(first.id(), second.id()); + assert_ne!(first.id(), handle(IconKind::Minimize).id()); + } +} diff --git a/crates/app-iced/src/lib.rs b/crates/app-iced/src/lib.rs new file mode 100644 index 0000000..4fe1497 --- /dev/null +++ b/crates/app-iced/src/lib.rs @@ -0,0 +1,55 @@ +//! # unlucky-app-iced · iced 版桌面壳(P4 / D26) +//! +//! 本 crate 是「UI 从头写」的第一步:**只做骨架**(能开窗、颜色/字体对、层级在), +//! 业务页面、组件库、i18n 全文案、玻璃 shader 都是后续 mission。 +//! +//! | 模块 | 职责 | 不负责 | +//! |---|---|---| +//! | [`theme`] | 令牌(`unlucky-design`)→ iced 类型:`Color` / `Font` / `Radius` / `Theme` | 发明令牌值 | +//! | `cli` | `--renderer` / `--software-renderer` / `--theme` / `--screenshot*` 参数 | 业务开关 | +//! | `probe` | 渲染后端探测(D27)+ 写 `ICED_BACKEND`,失败自动降级 CPU | 绘制 | +//! | `fonts` | 字体栈求值(D28 前半):平台族名 → 系统字体库校验 → 启动日志 | 中文字体下载 | +//! | `app` / `shell` | Elm 三件套 + 自绘标题栏 / 导航轨 / 内容骨架 | 业务逻辑 | +//! | `shot` | `window::screenshot` → PNG(验收通道) | 图像比对 | +//! | `icons` | 令牌里的 SVG path → iced `svg` 控件 | 画新图标 | +//! +//! ## 消费纪律 +//! +//! **组件层零硬编码色值/字号/半径/间距**:一律走 [`theme::Ui`]。守门测试在 +//! `tests/no_hardcoded_tokens.rs`(扫 `src/**`,出现 `#RRGGBB` / `Color::from_rgb(` 即红)。 +//! +//! ## 与 Slint 版的关系 +//! +//! 并存期:`crates/app` 仍出 `unlucky-launcher`,本 crate 出 `unlucky-launcher-iced`。 +//! 等价验收后翻牌(bin 改名 + 旧 crate 降级),步骤写在 +//! `DevDocs/SubAgentsTalk/app-iced-骨架与翻牌.md`。 + +pub mod shot; +pub mod theme; + +mod app; +mod cli; +mod fonts; +mod icons; +mod probe; +mod shell; + +pub use cli::{Backend, Options}; +pub use probe::Probe; + +/// 启动 UI(阻塞到窗口关闭)。渲染后端由 `probe` 在 `run()` 之前写进 `ICED_BACKEND`。 +pub fn run(options: Options) -> iced::Result { + app::run(options) +} + +/// 与 [`run`] 同义,但把命令行做成最终结论:`Options::from_env()` + 启动日志。 +/// +/// 二进制入口用 [`Options::from_env`] + [`run`] 自己拼(要额外处理 `--diff`),这个函数留给 +/// 集成测试与将来的 CLI 复用。 +/// +/// # Errors +/// 参数不合法时返回人话(不 panic、不 `exit`)。 +pub fn run_from_env() -> Result { + let options = Options::from_env()?; + Ok(run(options)) +} diff --git a/crates/app-iced/src/main.rs b/crates/app-iced/src/main.rs new file mode 100644 index 0000000..b55a913 --- /dev/null +++ b/crates/app-iced/src/main.rs @@ -0,0 +1,52 @@ +//! 入口。所有实际逻辑在 lib(`unlucky_app_iced`)里: +//! +//! - `--diff `:只读两张 PNG 做逐像素对比(D30 的 GPU/CPU 几何一致性检查),不开窗; +//! - 其余参数:开窗跑骨架,`--screenshot` 出图后退出。 +//! +//! 退出码:`0` 成功 / `1` 截图有差异(diff 模式)/ `2` 参数或运行错误。 + +use std::process::ExitCode; + +use unlucky_app_iced::shot; +use unlucky_app_iced::Options; + +fn main() -> ExitCode { + let options = match Options::from_env() { + Ok(options) => options, + Err(message) => { + eprintln!("[cli] {message}"); + eprintln!( + "用法:unlucky-launcher-iced [--renderer=auto|gpu|cpu] [--software-renderer] \ + [--theme=light|dark] [--screenshot ] [--screenshot-theme=light|dark] \ + [--scale-factor ] [--diff ]" + ); + return ExitCode::from(2); + } + }; + + if let Some((left, right)) = options.diff.clone() { + let scan = i32::try_from(options.diff_scan).unwrap_or(i32::MAX); + return match shot::diff_files_with_scan(&left, &right, scan) { + Ok(diff) => { + eprintln!("{}", diff.describe()); + if diff.is_identical() { + ExitCode::SUCCESS + } else { + ExitCode::from(1) + } + } + Err(message) => { + eprintln!("[diff] {message}"); + ExitCode::from(2) + } + }; + } + + match unlucky_app_iced::run(options) { + Ok(()) => ExitCode::SUCCESS, + Err(error) => { + eprintln!("[run] {error}"); + ExitCode::FAILURE + } + } +} diff --git a/crates/app-iced/src/probe.rs b/crates/app-iced/src/probe.rs new file mode 100644 index 0000000..ca33f08 --- /dev/null +++ b/crates/app-iced/src/probe.rs @@ -0,0 +1,238 @@ +//! 渲染后端探测 + 自动降级(D27)。 +//! +//! ## 为什么需要探测 +//! +//! iced 0.14 的 `Renderer` 本身就是「wgpu → tiny-skia」的运行时回退枚举(M60 Q1): +//! **不设** `ICED_BACKEND` 时,拿不到 GPU 适配器会自动降级到 CPU,零代码。 +//! 需要自研的只有**判据**: +//! +//! - 本机会枚举出 `Microsoft Basic Render Driver [Cpu]`(= WARP 软光栅), +//! 所以「有没有适配器」不能当「有没有 GPU」(M60 实测),必须看 `device_type`。 +//! - 没有硬件 GPU 时要**主动**把 CPU 放到候选首位(`tiny-skia,wgpu`), +//! 否则 wgpu 会先花时间尝试 WARP,观感与行为都难料。 +//! +//! ## 语义订正(与 M60 备忘录的差异,以源码 + 实测为准) +//! +//! M60 记「`ICED_BACKEND=wgpu` 会**关闭**回退」。读 `iced_renderer-0.14.0/src/fallback.rs` +//! 得到的是:**候选列表**语义——列表里没有 `tiny-skia` 时才真的关闭回退。 +//! 所以本 crate 的「优先 GPU」写 `wgpu,tiny-skia`(首选 GPU,拿不到仍降级,**绝不硬失败**), +//! 而不是 `wgpu`。 + +use crate::Backend; + +/// 单个适配器的摘要(只留日志要用的字段)。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AdapterSummary { + /// 适配器名(如 `AMD Radeon RX 6600 XT`)。 + pub name: String, + /// `device_type` 的字符串形式(`DiscreteGpu` / `IntegratedGpu` / `Cpu` / …)。 + pub device_type: &'static str, +} + +/// 探测结论。 +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct Probe { + /// 枚举到的全部适配器(含 WARP 这类软光栅)。 + pub adapters: Vec, + /// 是否存在**真**硬件适配器(`device_type` 既不是 `Cpu` 也不是 `Other`)。 + pub hardware: bool, +} + +impl Probe { + /// 探测本机渲染能力(编入 `wgpu` 时真的枚举适配器)。 + #[cfg(feature = "gpu")] + pub fn run() -> Self { + run_wgpu() + } + + /// 探测本机渲染能力(`--no-default-features` 的纯 CPU 构建:没有 wgpu,直接判定无硬件)。 + #[cfg(not(feature = "gpu"))] + pub fn run() -> Self { + Self::default() + } + + /// 一行日志:适配器清单 + 判定。 + pub fn describe(&self) -> String { + if self.adapters.is_empty() { + return format!("adapters=0 hardware={}", self.hardware); + } + + let list = self + .adapters + .iter() + .map(|adapter| format!("{} [{}]", adapter.name, adapter.device_type)) + .collect::>() + .join(" | "); + + format!( + "adapters={} [{list}] hardware={}", + self.adapters.len(), + self.hardware + ) + } +} + +/// 按请求与探测结果决定 `ICED_BACKEND` 的值(`None` = 不干预,交给 iced 自己回退)。 +/// +/// 纯函数,好让「降级矩阵」被测试钉死。 +pub fn backend_env(request: Backend, probe: &Probe, gpu_compiled: bool) -> Option<&'static str> { + match (request, gpu_compiled) { + // 命令行 --software-renderer / --renderer=cpu:只留 CPU 候选。 + (Backend::Cpu, _) => Some("tiny-skia"), + // 用户点名要 GPU,但没编进 wgpu:只能 CPU,日志里说明。 + (Backend::Gpu, false) => Some("tiny-skia"), + // 优先 GPU,但保留 tiny-skia 兜底(候选列表语义:不会硬失败)。 + (Backend::Gpu, true) => Some("wgpu,tiny-skia"), + // 自动:有硬件就让 iced 走默认顺序(wgpu 优先,自动回退)。 + (Backend::Auto, true) if probe.hardware => None, + // 自动 + 没编进 wgpu:只有 CPU 候选,别把 wgpu 写进列表(会与「未编入 wgpu」的日志自相矛盾)。 + (Backend::Auto, false) => Some("tiny-skia"), + // 自动 + 有 wgpu 但没硬件(含只有 WARP 的机器):CPU 起步,wgpu 留作兜底。 + (Backend::Auto, true) => Some("tiny-skia,wgpu"), + } +} + +/// 把决策落到进程环境变量,并把探测结果 + 生效值打进日志。 +/// +/// 返回**实际生效**的后端描述(`"auto (iced: wgpu -> tiny-skia)"` 之类)。 +pub fn apply(request: Backend, probe: &Probe) -> String { + let gpu_compiled = cfg!(feature = "gpu"); + let env = backend_env(request, probe, gpu_compiled); + + // ICED_BACKEND 是进程级环境变量,必须在 `iced::application(..).run()` 之前写。 + // 仓库是 edition 2021 → `set_var` 仍是安全 API(升 2024 后要改成子进程 exec 自身)。 + if let Some(value) = env { + std::env::set_var("ICED_BACKEND", value); + } + + let effective = if gpu_compiled { + match (request, env) { + (Backend::Cpu, _) => "tiny-skia (CPU,强制)".to_owned(), + (_, None) => "auto (wgpu -> tiny-skia)".to_owned(), + (_, Some("wgpu,tiny-skia")) => "wgpu.then(tiny-skia) (优先 GPU)".to_owned(), + (_, Some(value)) => format!("{value} (CPU 起步)"), + } + } else { + "tiny-skia (本构建未编入 wgpu,只有 CPU)".to_owned() + }; + + let note = if !gpu_compiled && request != Backend::Cpu { + ";本构建未编入 wgpu(--no-default-features),只能 CPU" + } else { + "" + }; + + eprintln!( + "[probe] {} :: request={} -> ICED_BACKEND={}{note}", + probe.describe(), + request.name(), + env.unwrap_or("") + ); + eprintln!("[probe] renderer = {effective}"); + + effective +} + +#[cfg(feature = "gpu")] +fn run_wgpu() -> Probe { + // `iced::wgpu` 就是 wgpu crate 本身(`pub use iced_renderer::wgpu::wgpu` 再导出), + // 所以不需要单独依赖 wgpu,版本也不会和 iced 打架。 + use iced::wgpu::{Backends, DeviceType, Instance, InstanceDescriptor}; + + let instance = Instance::new(&InstanceDescriptor { + backends: Backends::all(), + ..Default::default() + }); + + let adapters = instance + .enumerate_adapters(Backends::all()) + .into_iter() + .map(|adapter| { + let info = adapter.get_info(); + let device_type = match info.device_type { + DeviceType::DiscreteGpu => "DiscreteGpu", + DeviceType::IntegratedGpu => "IntegratedGpu", + DeviceType::VirtualGpu => "VirtualGpu", + DeviceType::Cpu => "Cpu", + DeviceType::Other => "Other", + }; + AdapterSummary { + name: info.name, + device_type, + } + }) + .collect::>(); + + let hardware = adapters + .iter() + .any(|adapter| !matches!(adapter.device_type, "Cpu" | "Other")); + + Probe { adapters, hardware } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn probe(types: &[&'static str]) -> Probe { + let adapters = types + .iter() + .map(|device_type| AdapterSummary { + name: "adapter".to_owned(), + device_type, + }) + .collect::>(); + Probe { + adapters, + hardware: types + .iter() + .any(|device_type| !matches!(*device_type, "Cpu" | "Other")), + } + } + + #[test] + fn warp_only_machine_counts_as_no_gpu() { + // 本机实测会枚举出 WARP:`Microsoft Basic Render Driver [Cpu]` + let warp = probe(&["Cpu"]); + assert!(!warp.hardware, "只有 WARP 不能当「有 GPU」"); + assert_eq!( + backend_env(Backend::Auto, &warp, true), + Some("tiny-skia,wgpu") + ); + } + + #[test] + fn auto_with_real_gpu_leaves_iced_alone() { + let gpu = probe(&["DiscreteGpu", "Cpu"]); + assert!(gpu.hardware); + assert_eq!(backend_env(Backend::Auto, &gpu, true), None); + } + + #[test] + fn explicit_choices_always_keep_a_fallback() { + let gpu = probe(&["DiscreteGpu"]); + let none = probe(&[]); + // 优先 GPU 但候选列表里留 tiny-skia:拿不到适配器时降级而不是报错 + assert_eq!( + backend_env(Backend::Gpu, &gpu, true), + Some("wgpu,tiny-skia") + ); + assert_eq!( + backend_env(Backend::Gpu, &none, true), + Some("wgpu,tiny-skia") + ); + assert_eq!(backend_env(Backend::Cpu, &gpu, true), Some("tiny-skia")); + // 没编进 wgpu 的构建绝不写 wgpu(连候选列表也不许出现,reviewer P3-4) + assert_eq!(backend_env(Backend::Gpu, &gpu, false), Some("tiny-skia")); + assert_eq!(backend_env(Backend::Auto, &gpu, false), Some("tiny-skia")); + assert_eq!(backend_env(Backend::Auto, &none, false), Some("tiny-skia")); + } + + #[test] + fn describe_lists_every_adapter() { + let text = probe(&["DiscreteGpu", "Cpu"]).describe(); + assert!(text.contains("DiscreteGpu"), "{text}"); + assert!(text.contains("Cpu"), "{text}"); + assert!(text.contains("hardware=true"), "{text}"); + } +} diff --git a/crates/app-iced/src/shell/mod.rs b/crates/app-iced/src/shell/mod.rs new file mode 100644 index 0000000..6b85b85 --- /dev/null +++ b/crates/app-iced/src/shell/mod.rs @@ -0,0 +1,349 @@ +//! 首屏骨架(**不做业务**):自绘标题栏 + 左侧导航轨 + 内容区(页头 + 一张卡片)。 +//! +//! ## 与参考判据的对应(`research/12b-component-fidelity.md`) +//! +//! | 部件 | 参考 | 令牌落点 | +//! |---|---|---| +//! | 标题栏高度 | 16.2 桌面标题栏 **40**(登记偏离) | `LM.titlebar_height = 48`(M63 §1.4/R13 桌面档,令牌优先) | +//! | 标题栏按钮 | 16.5 图标 **24** | `IconSize::Toolbar` + `LM.titlebar_button_width = 46` | +//! | 页头 | 16.1 官方 AppBar **64**;本项目 **56** | `LM.page_header_height = 56`(已有偏离登记) | +//! | 卡片 | 3.x Outlined:**Medium(12)**、L0、`OutlineVariant` 1px、`SurfaceContainerLow` | `Component::OutlinedCard.spec()` + `STROKE_PX` | +//! | 字级 | 16.4 标题 `TitleLarge`;卡片 `TitleMedium` / `BodySmall` | `Ui::style` / `Ui::emphasized` | +//! +//! 导航轨在 [`nav`] 子模块里(12b §5 的判据表也在那边)。 +//! +//! 一屏只出现 4 档字号(22 / 16 / 14 / 12),且**没有实心 accent**(rubric K2:一屏最多一个实心 +//! Primary,骨架期一个都不要)。阴影只给浮层,骨架里没有浮层,所以**全屏零阴影**(rubric K3)。 + +mod nav; + +pub use nav::NavItem; + +use iced::widget::text::LineHeight; +use iced::widget::{button, column, container, mouse_area, row, space, stack, text}; +use iced::{mouse, Alignment, Background, Border, Color, Element, Length, Padding, Pixels, Shadow}; +use iced::{window::Direction, Theme as IcedTheme}; + +use unlucky_design::color::{Role, StateLayer}; +use unlucky_design::component_map::Component; +use unlucky_design::elevation::MaterialLayer; +use unlucky_design::icons::IconKind; +use unlucky_design::spacing::Space; +use unlucky_design::typography::TypeRole; + +use crate::app::Message; +use crate::cli::ForcedState; +use crate::icons; +use crate::theme::Ui; + +/// 整棵树。 +pub fn view<'a>( + ui: &'a Ui, + selected: NavItem, + app_title: &'a str, + page_title: &'a str, + body: &'a str, + forced: Option, +) -> Element<'a, Message> { + let work_area = column![ + titlebar(ui, app_title, forced), + row![ + nav::rail(ui, selected, forced), + content(ui, page_title, body) + ] + .width(Length::Fill) + .height(Length::Fill), + ] + .width(Length::Fill) + .height(Length::Fill); + + // 无边框窗口没有系统缩放边框 → 自己给两条热区(东/南)。 + // **必须叠层而不是兄弟列**:当兄弟列会吃掉 4px 布局宽(右留白变 28、caption 按钮离窗角 4px, + // 视觉复审 P3-1)。叠层容器自己铺满,再用 align 把 4px 热区贴到边上,布局宽保持 1180。 + // 东侧热区从标题栏下方开始,避免吃掉三个 caption 按钮最右 4px 的点击区。 + let thickness = ui.space(Space::X1); + + let east = container( + column![ + space::vertical().height(ui.metrics().titlebar_height), + resize_edge( + Direction::East, + (Length::Fixed(thickness), Length::Fill), + mouse::Interaction::ResizingHorizontally, + ), + ] + .height(Length::Fill), + ) + .width(Length::Fill) + .height(Length::Fill) + .align_x(Alignment::End); + + let south = container(resize_edge( + Direction::South, + (Length::Fill, Length::Fixed(thickness)), + mouse::Interaction::ResizingVertically, + )) + .width(Length::Fill) + .height(Length::Fill) + .align_y(Alignment::End); + + let shell = stack![work_area, east, south]; + + container(shell) + .width(Length::Fill) + .height(Length::Fill) + .style(|_theme| filled(ui.layer(MaterialLayer::Canvas))) + .into() +} + +/// 窗口缩放热区(透明;玻璃/底色由根容器提供)。 +/// +/// 拖动时调 `window::drag_resize(id, direction)`——Windows/X11 走 winit 原生实现, +/// **macOS 返回 `NotSupported`**(M60 Q4),如实登记在交接文档里。 +fn resize_edge<'a>( + direction: Direction, + size: (Length, Length), + interaction: mouse::Interaction, +) -> Element<'a, Message> { + let (width, height) = size; + + mouse_area(container(space::horizontal()).width(width).height(height)) + .interaction(interaction) + .on_press(Message::ResizeStart(direction)) + .into() +} + +// ---------------------------------------------------------------- 标题栏 + +fn titlebar<'a>(ui: &'a Ui, title: &'a str, forced: Option) -> Element<'a, Message> { + let metrics = ui.metrics(); + + // 拖动热区:从窗口左缘到第一个按钮(按钮自己不能被拖区吞掉,所以只包左侧一段) + let drag_area = mouse_area( + container(label( + ui, + TypeRole::LabelLarge, + true, + title, + ui.color(Role::OnSurface), + )) + .width(Length::Fill) + .height(Length::Fill) + .align_y(Alignment::Center) + .padding(Padding::from([0.0, ui.space(Space::X4)])), + ) + .on_press(Message::DragWindow); + + let bar = row![ + drag_area, + caption(ui, IconKind::Minimize, Message::Minimize, forced), + caption(ui, IconKind::Maximize, Message::ToggleMaximize, forced), + caption(ui, IconKind::Close, Message::Close, forced), + ] + .width(Length::Fill) + .height(metrics.titlebar_height) + .align_y(Alignment::Center); + + container(bar) + .width(Length::Fill) + .height(metrics.titlebar_height) + .style(|_theme| filled(ui.layer(MaterialLayer::Canvas))) + .into() +} + +/// 标题栏按钮(自绘最小化/最大化/关闭)。 +/// +/// 图标必须**再包一层固定 24×24 的容器**:iced 的 `button` 在 layout 里给内容 Fill 约束, +/// 直接把 `svg(...).width(24)` 塞进 button,usvg 会按 Contain 撑满内区(实测渲染盒 46 → 视觉线宽 +/// 从 1.5 变 2.9,视觉复审 P2-2)。Container 尊重子元素的 Length,所以这层是「锁尺寸」用的。 +fn caption<'a>( + ui: &'a Ui, + kind: IconKind, + message: Message, + forced: Option, +) -> Element<'a, Message> { + let metrics = ui.metrics(); + // 用 `center(Length::Fill)` 而不是只设 `width(24).height(24)`:后者会让容器缩到 24×24 并贴到 + // button 内容的左上角(button 不居中「收缩后」的内容,r1 实测图标跑到 cell 左上 (left+11.5, 11.5)); + // `center(Fill)` = 撑满按钮内容区 + 内容居中,字形仍是 24 盒、命中区仍是 46×48。 + let icon = container(icons::view::( + kind, + ui.color(Role::OnSurfaceVariant), + )) + .width(icons::RENDER_PX) + .height(icons::RENDER_PX) + .center(Length::Fill); + + button(icon) + .width(metrics.titlebar_button_width) + .height(metrics.titlebar_height) + .padding(0) + .style(move |_theme: &IcedTheme, status| { + let background = match overridden(forced, status) { + button::Status::Hovered => Some(Background::Color(ui.state_over( + StateLayer::Hover, + Role::OnSurface, + Role::Surface, + ))), + button::Status::Pressed => Some(Background::Color(ui.state_over( + StateLayer::Pressed, + Role::OnSurface, + Role::Surface, + ))), + button::Status::Active | button::Status::Disabled => None, + }; + + button::Style { + background, + text_color: ui.color(Role::OnSurfaceVariant), + border: Border::default(), + shadow: Shadow::default(), + snap: true, + } + }) + .on_press(message) + .into() +} + +// ---------------------------------------------------------------- 内容区 + +fn content<'a>(ui: &'a Ui, title: &'a str, body: &'a str) -> Element<'a, Message> { + let metrics = ui.metrics(); + // 卡片档位由组件表决定(圆角/字级),组件层不自己挑 + let card_spec = Component::OutlinedCard.spec(); + let body_role = card_spec.support.unwrap_or(card_spec.text); + + let header = container(label( + ui, + TypeRole::TitleLarge, + true, + title, + ui.color(Role::OnSurface), + )) + .width(Length::Fill) + .height(metrics.page_header_height) + .align_y(Alignment::Center) + .padding(Padding::from([0.0, ui.space(Space::X6)])); + + let card = container( + column![ + label( + ui, + card_spec.text, + true, + "Shell ready", + ui.color(Role::OnSurface), + ), + label(ui, body_role, false, body, ui.color(Role::OnSurfaceVariant)), + ] + .spacing(ui.space(Space::X2)), + ) + .width(Length::Fill) + .padding(metrics.card_padding) + .style(move |_theme| container::Style { + background: Some(Background::Color(ui.layer(MaterialLayer::Card))), + border: ui.outlined(card_spec.shape), + ..container::Style::default() + }); + + let body_area = container(card) + .width(Length::Fill) + .height(Length::Fill) + .padding(ui.space(Space::X6)) + .align_y(Alignment::Start); + + column![header, body_area] + .width(Length::Fill) + .height(Length::Fill) + .into() +} + +// ---------------------------------------------------------------- 小工具 + +/// 一个文字元素:字号/行高/字重全部来自令牌排版档。 +pub(super) fn label<'a>( + ui: &'a Ui, + role: TypeRole, + emphasized: bool, + content: &'a str, + color: Color, +) -> Element<'a, Message> { + let style = if emphasized { + ui.emphasized(role) + } else { + ui.style(role) + }; + + text(content) + .size(style.size_px) + .line_height(LineHeight::Absolute(Pixels(style.line_height_px))) + .font(ui.font_of(style)) + .color(color) + .into() +} + +/// 取证用的状态覆盖(`--force-state`):只改画出来的状态,不动任何状态机。 +pub(super) fn overridden(forced: Option, status: button::Status) -> button::Status { + match forced { + Some(ForcedState::Hover) => button::Status::Hovered, + Some(ForcedState::Pressed) => button::Status::Pressed, + None => status, + } +} + +pub(super) fn filled(color: Color) -> container::Style { + container::Style { + background: Some(Background::Color(color)), + ..container::Style::default() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use unlucky_design::color::SchemeFlavor; + use unlucky_design::typography::Platform; + + use crate::theme::Families; + + fn ui(flavor: SchemeFlavor) -> Ui { + Ui::with_platform( + flavor, + Platform::Windows, + Families { + text: "Segoe UI", + display: "Segoe UI", + mono: "Consolas", + }, + ) + } + + #[test] + fn a_screen_only_uses_four_text_sizes_at_most() { + let ui = ui(SchemeFlavor::Dark); + let card = Component::OutlinedCard.spec(); + let sizes = [ + ui.emphasized(TypeRole::TitleLarge).size_px, + ui.emphasized(TypeRole::LabelLarge).size_px, + ui.emphasized(card.text).size_px, + ui.style(card.support.unwrap_or(card.text)).size_px, + ui.style(TypeRole::LabelMedium).size_px, + ]; + let mut unique = sizes.to_vec(); + unique.sort_by(f32::total_cmp); + unique.dedup(); + assert!( + unique.len() <= 4, + "rubric D4:一屏字号档 ≤4(实际 {} 档 {unique:?})", + unique.len() + ); + assert!(unique[0] >= 12.0, "最小档不下 12px(caption ≥11)"); + assert!(unique[unique.len() - 1] <= 28.0, "列表页标题不得 >28px"); + } + + #[test] + fn flavor_slug_is_stable_for_filenames() { + assert_eq!(crate::shot::flavor_slug(&ui(SchemeFlavor::Light)), "light"); + assert_eq!(crate::shot::flavor_slug(&ui(SchemeFlavor::Dark)), "dark"); + } +} diff --git a/crates/app-iced/src/shell/nav.rs b/crates/app-iced/src/shell/nav.rs new file mode 100644 index 0000000..bfd3f7d --- /dev/null +++ b/crates/app-iced/src/shell/nav.rs @@ -0,0 +1,258 @@ +//! 左侧导航轨(M3 `NavigationRail`)。 +//! +//! 判据来源 `research/12b-component-fidelity.md` §5: +//! +//! | 项 | 参考值 | 令牌落点 | +//! |---|---|---| +//! | 轨宽 | 5.1 官方 96(narrow 80);本项目 **88** | [`nav_rail_width`](`LayoutMetrics` 骨架值,详见该函数文档的 M69 迁移说明) | +//! | 轨容器 | 5.3 形状 None / L0 / `Surface` | `MaterialLayer::Canvas` | +//! | 选中指示器 | 5.4 **56×32**、圆角 full、`SecondaryContainer` | [`indicator_size`](令牌推导) | +//! | 图标 | 5.5 24px | `IconSize::Toolbar` | +//! | 标签 | 5.5 `LabelMedium 12/16` | `TypeRole::LabelMedium` | +//! | 颜色 | 5.6 选中图标 `OnSecondaryContainer` / 选中标签 `Secondary` / 未选中 `OnSurfaceVariant` | `Ui::color` | +//! | 项间距 | 5.7 **4** | `LayoutMetrics::nav_item_gap` | + +use iced::widget::{button, column, container}; +use iced::{Alignment, Background, Border, Element, Length, Padding, Shadow, Theme as IcedTheme}; + +use unlucky_design::color::{Role, StateLayer}; +use unlucky_design::elevation::MaterialLayer; +use unlucky_design::icons::IconKind; +use unlucky_design::shape::ShapeScale; +use unlucky_design::spacing::{LayoutMetrics, Space}; +use unlucky_design::typography::TypeRole; + +use crate::app::Message; +use crate::cli::ForcedState; +use crate::icons; +use crate::shell::{filled, label, overridden}; +use crate::theme::Ui; + +/// 导航占位项(任务要求 4~6 个;真正的页面由后续 mission 接管)。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum NavItem { + /// 实例列表。 + Instances, + /// 下载/版本。 + Download, + /// 日志。 + Logs, + /// 设置。 + Settings, + /// AI 助手。 + Ai, +} + +impl NavItem { + /// 全部占位项(顺序 = 导航栏自上而下)。 + pub const ALL: [NavItem; 5] = [ + NavItem::Instances, + NavItem::Download, + NavItem::Logs, + NavItem::Settings, + NavItem::Ai, + ]; + + /// 显示名(i18n 是后续 mission;本 mission 用英文基线文案,避免干净 CI 上缺中文变豆腐块)。 + pub const fn label(self) -> &'static str { + match self { + NavItem::Instances => "Instances", + NavItem::Download => "Download", + NavItem::Logs => "Logs", + NavItem::Settings => "Settings", + NavItem::Ai => "Assistant", + } + } + + /// 令牌图标。 + pub const fn icon(self) -> IconKind { + match self { + NavItem::Instances => IconKind::Instances, + NavItem::Download => IconKind::Download, + NavItem::Logs => IconKind::Logs, + NavItem::Settings => IconKind::Settings, + NavItem::Ai => IconKind::Ai, + } + } +} + +/// 整条导航轨。 +pub(super) fn rail<'a>( + ui: &'a Ui, + selected: NavItem, + forced: Option, +) -> Element<'a, Message> { + let metrics = ui.metrics(); + let indicator = indicator_size(metrics); + + let items = NavItem::ALL + .iter() + .map(|item| rail_item(ui, *item, *item == selected, indicator, forced)) + .collect::>(); + + // 轨顶留白走 container padding,而不是「先塞一个 spacer 元素」:后者会被 column 的 + // spacing(4)再加一档,实测变 28(视觉复审 P3-5)。 + container(column(items).spacing(metrics.nav_item_gap)) + .width(nav_rail_width(metrics)) + .height(Length::Fill) + .padding(Padding::from([ui.space(Space::X6), 0.0])) + .style(|_theme| filled(ui.layer(MaterialLayer::Canvas))) + .into() +} + +fn rail_item<'a>( + ui: &'a Ui, + item: NavItem, + selected: bool, + indicator: (f32, f32), + forced: Option, +) -> Element<'a, Message> { + let (indicator_width, indicator_height) = indicator; + + let icon_color = if selected { + ui.color(Role::OnSecondaryContainer) + } else { + ui.color(Role::OnSurfaceVariant) + }; + // 12b 5.6:选中标签用 `Secondary`,未选中 `OnSurfaceVariant` + let label_color = if selected { + ui.color(Role::Secondary) + } else { + ui.color(Role::OnSurfaceVariant) + }; + + // **不要用 `center_x/center_y(Fill)`**:那会把容器自己的 width/height 改成 Fill, + // 于是 56 被父级(button 宽 88)覆盖成 88、32 靠布局凑巧(视觉复审 P2-1)。 + // 尺寸只认令牌推导值,居中交给父 column 的 `align_x`。 + let pill = container(icons::view::(item.icon(), icon_color)) + .width(indicator_width) + .height(indicator_height) + .align_x(Alignment::Center) + .align_y(Alignment::Center) + .style(move |_theme| container::Style { + background: selected.then(|| Background::Color(ui.color(Role::SecondaryContainer))), + border: Border { + radius: ui.radius(ShapeScale::Full, indicator_height), + ..Border::default() + }, + ..container::Style::default() + }); + + let item_height = nav_item_height(ui, indicator_height); + // 列必须显式占满轨宽,否则它会 shrink 到 56,`align_x(Center)` 就变成空操作, + // 药丸会贴左缘(x=0..55 而不是 16..71)——r1 实测踩到的第二个坑。 + let stack = column![ + pill, + label(ui, TypeRole::LabelMedium, false, item.label(), label_color), + ] + .spacing(ui.space(Space::X1)) + .width(nav_rail_width(ui.metrics())) + .align_x(Alignment::Center) + .height(item_height); + + button(stack) + .width(nav_rail_width(ui.metrics())) + .height(item_height) + .padding(0) + .style(move |_theme: &IcedTheme, status| { + let background = match overridden(forced, status) { + button::Status::Hovered if !selected => Some(Background::Color(ui.state_over( + StateLayer::Hover, + Role::OnSurface, + Role::Surface, + ))), + button::Status::Pressed if !selected => Some(Background::Color(ui.state_over( + StateLayer::Pressed, + Role::OnSurface, + Role::Surface, + ))), + _ => None, + }; + + button::Style { + background, + text_color: label_color, + border: Border::default(), + shadow: Shadow::default(), + snap: true, + } + }) + .on_press(Message::Select(item)) + .into() +} + +/// 导航轨宽度(M69 起是令牌层的 `nav_rail_width`)。 +/// +/// M69(`141e9b1`)把 `LayoutMetrics` 的 nav 字段做了语义纠正: +/// +/// | 旧 | 新 | +/// |---|---| +/// | `nav_expanded_width = 88` | **`nav_rail_width = 88`**(值不变,名字从「展开」改成「轨」) | +/// | `nav_collapsed_width = 48` | 删除(48 连 56 宽的选中指示器都放不下) | +/// | —(缺) | `nav_drawer_width = 240`(D33 的展开态抽屉,本 crate 未用) | +/// +/// 三处调用点都走这里:将来令牌再改名,仍然只改这一行。 +fn nav_rail_width(metrics: LayoutMetrics) -> f32 { + metrics.nav_rail_width +} + +/// 选中指示器尺寸(12b 5.4 参考 56×32),全部由令牌推导、不写死: +/// +/// - 宽 = [`nav_rail_width`] − 2 × `Space::X4` = 88 − 32 = **56**(轨的左右内边距 16) +/// - 高 = `control_height − Space::X2` = 40 − 8 = **32**(可见高度上下各让 4) +pub fn indicator_size(metrics: LayoutMetrics) -> (f32, f32) { + ( + nav_rail_width(metrics) - Space::X4.dp() * 2.0, + metrics.control_height - Space::X2.dp(), + ) +} + +/// 导航项高度 = 指示器 + 4 + 标签行高 = 32 + 4 + 16 = 52(`LM.nav_item_height` 是列表行节奏, +/// 药丸 + 标签的轨项放不进 44,偏差登记在交接文档里)。 +pub fn nav_item_height(ui: &Ui, indicator_height: f32) -> f32 { + indicator_height + ui.space(Space::X1) + ui.style(TypeRole::LabelMedium).line_height_px +} + +#[cfg(test)] +mod tests { + use super::*; + use unlucky_design::color::SchemeFlavor; + use unlucky_design::typography::Platform; + + use crate::theme::Families; + + fn ui() -> Ui { + Ui::with_platform( + SchemeFlavor::Light, + Platform::Windows, + Families { + text: "Segoe UI", + display: "Segoe UI", + mono: "Consolas", + }, + ) + } + + #[test] + fn indicator_matches_the_reference_56x32() { + let (width, height) = indicator_size(LayoutMetrics::DESKTOP); + assert_eq!((width, height), (56.0, 32.0), "12b 5.4 的参考值"); + } + + #[test] + fn nav_item_height_is_indicator_plus_gap_plus_label() { + let ui = ui(); + let (_, indicator_height) = indicator_size(ui.metrics()); + // 32 + 4 + 16 + assert_eq!(nav_item_height(&ui, indicator_height), 52.0); + } + + #[test] + fn every_nav_item_has_a_label_and_an_icon() { + assert!((4..=6).contains(&NavItem::ALL.len())); + for item in NavItem::ALL { + assert!(!item.label().is_empty(), "{item:?} 没有文案"); + assert!(!item.icon().icon().path.is_empty(), "{item:?} 没有图标"); + } + } +} diff --git a/crates/app-iced/src/shot/diff.rs b/crates/app-iced/src/shot/diff.rs new file mode 100644 index 0000000..1136891 --- /dev/null +++ b/crates/app-iced/src/shot/diff.rs @@ -0,0 +1,352 @@ +//! 两图逐像素对比(GPU/CPU 几何一致性)与 ±N px 平移扫描。 +//! +//! 拆成子模块只为「src 文件 ≤400 行」的门禁;对外通过 `shot::{compare, diff_files, Diff, Image}` +//! 使用,调用方不用感知这层。 + +use std::path::Path; + +/// 一张已解码的 RGBA 图。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Image { + /// RGBA8 字节(逐像素 4 字节)。 + pub rgba: Vec, + /// 宽(px)。 + pub width: u32, + /// 高(px)。 + pub height: u32, +} + +impl Image { + /// 读 PNG(只支持 PNG:本 crate 的 `image` 只开了 png 编码/解码)。 + /// + /// # Errors + /// 读文件失败或解码失败时返回人话。 + pub fn load(path: &Path) -> Result { + let reader = image::ImageReader::open(path) + .map_err(|error| format!("读图 {} 失败:{error}", path.display()))? + .with_guessed_format() + .map_err(|error| format!("判定 {} 的格式失败:{error}", path.display()))?; + + let decoded = reader + .decode() + .map_err(|error| format!("解码 {} 失败:{error}", path.display()))? + .to_rgba8(); + + Ok(Self { + width: decoded.width(), + height: decoded.height(), + rgba: decoded.into_raw(), + }) + } +} + +/// 两图对比结果(GPU/CPU 一致性靠它给数字,不靠肉眼)。 +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Diff { + /// 宽(px)。 + pub width: u32, + /// 高(px)。 + pub height: u32, + /// 有差异的像素数。 + pub differing_pixels: usize, + /// 单通道最大差(0~255)。 + pub max_channel_delta: u8, + /// 单通道平均差(0~255)。 + pub mean_channel_delta: f64, + /// 平移扫描的最佳对齐(dx, dy);不扫描时为 (0, 0)。 + pub best_shift: (i32, i32), + /// 最佳对齐下的差异像素数(不扫描时等于 `differing_pixels`)。 + pub best_differing_pixels: usize, +} + +/// 逐像素对比两张尺寸相同的图。 +/// +/// # Errors +/// 尺寸不同直接报错——**几何不一致不许静默通过**。 +pub fn compare(left: &Image, right: &Image) -> Result { + if (left.width, left.height) != (right.width, right.height) { + return Err(format!( + "尺寸不同:{}x{} vs {}x{}", + left.width, left.height, right.width, right.height + )); + } + + let pixel_count = left.rgba.len() / 4; + let mut differing = 0_usize; + let mut max_delta = 0_u8; + let mut sum_delta = 0_u64; + + for index in 0..pixel_count { + let offset = index * 4; + let mut pixel_differs = false; + + for channel in 0..4 { + let delta = left.rgba[offset + channel].abs_diff(right.rgba[offset + channel]); + if delta > 0 { + pixel_differs = true; + max_delta = max_delta.max(delta); + sum_delta += u64::from(delta); + } + } + + if pixel_differs { + differing += 1; + } + } + + Ok(Diff { + width: left.width, + height: left.height, + differing_pixels: differing, + max_channel_delta: max_delta, + mean_channel_delta: if pixel_count == 0 { + 0.0 + } else { + sum_delta as f64 / (pixel_count * 4) as f64 + }, + best_shift: (0, 0), + best_differing_pixels: differing, + }) +} + +/// ±`max_shift` px 的平移扫描:把 `right` 平移后再比,返回差异像素数最小的那一档。 +/// +/// 用途:D30 的「几何逐像素一致」除了看差异占比,还要证明**没有整体错位**—— +/// 如果 (0,0) 就是最优,说明差异只来自抗锯齿而不是位移/缩放。CI 不必再依赖 numpy。 +/// +/// 平移后越界的像素按「无法比较」处理,不计入差异(否则边缘会造成假阳性)。 +pub fn shift_scan(left: &Image, right: &Image, max_shift: i32) -> Result { + let mut best = compare(left, right)?; + let max_shift = max_shift.unsigned_abs(); + + if (left.width, left.height) != (right.width, right.height) { + return Err("尺寸不同,无法做平移扫描".to_owned()); + } + + for dy in -(max_shift as i32)..=(max_shift as i32) { + for dx in -(max_shift as i32)..=(max_shift as i32) { + if (dx, dy) == (0, 0) { + continue; + } + + let count = shifted_differing_pixels(left, right, dx, dy); + if count < best.best_differing_pixels { + best.best_differing_pixels = count; + best.best_shift = (dx, dy); + } + } + } + + Ok(best) +} + +/// 把 `right` 平移 `(dx, dy)` 后与 `left` 比较,返回差异像素数(越界不算)。 +fn shifted_differing_pixels(left: &Image, right: &Image, dx: i32, dy: i32) -> usize { + let width = left.width as i32; + let height = left.height as i32; + let mut differing = 0; + + for y in 0..height { + let source_y = y - dy; + if !(0..height).contains(&source_y) { + continue; + } + + for x in 0..width { + let source_x = x - dx; + if !(0..width).contains(&source_x) { + continue; + } + + let left_offset = ((y * width + x) * 4) as usize; + let right_offset = ((source_y * width + source_x) * 4) as usize; + + if left.rgba[left_offset..left_offset + 4] != right.rgba[right_offset..right_offset + 4] + { + differing += 1; + } + } + } + + differing +} + +impl Diff { + /// 完全一致(几何 + 颜色都一致)。 + pub fn is_identical(&self) -> bool { + self.differing_pixels == 0 + } + + /// 一行日志。 + pub fn describe(&self) -> String { + let total = (self.width as usize) * (self.height as usize); + let percent = if total == 0 { + 0.0 + } else { + self.differing_pixels as f64 * 100.0 / total as f64 + }; + + format!( + "[diff] {}x{} differing={}/{} ({percent:.2}%) max_delta={} mean_delta={:.4} best_shift=({},{}) best_differing={}", + self.width, + self.height, + self.differing_pixels, + total, + self.max_channel_delta, + self.mean_channel_delta, + self.best_shift.0, + self.best_shift.1, + self.best_differing_pixels, + ) + } +} + +/// `--diff ` 的实现:读两张 PNG、对比、打日志。 +/// +/// # Errors +/// 任一图读不出来,或尺寸不同。 +pub fn diff_files(left: &Path, right: &Path) -> Result { + diff_files_with_scan(left, right, 0) +} + +/// 同 [`diff_files`],但额外做 ±`max_shift` px 的平移扫描(`--diff-scan`)。 +/// +/// # Errors +/// 任一图读不出来,或尺寸不同。 +pub fn diff_files_with_scan(left: &Path, right: &Path, max_shift: i32) -> Result { + let left = Image::load(left)?; + let right = Image::load(right)?; + + if max_shift == 0 { + compare(&left, &right) + } else { + shift_scan(&left, &right, max_shift) + } +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use iced::window; + + use super::*; + use crate::shot::{save, write_rgba}; + + fn temp_dir() -> PathBuf { + let dir = std::env::temp_dir().join("ull-app-iced-shot-tests"); + std::fs::create_dir_all(&dir).expect("建临时目录"); + dir + } + + #[test] + fn identical_images_have_zero_diff() { + let image = Image { + rgba: vec![7_u8; 4 * 4], + width: 2, + height: 2, + }; + let diff = compare(&image, &image).unwrap(); + assert!(diff.is_identical()); + assert_eq!(diff.max_channel_delta, 0); + } + + #[test] + fn geometry_mismatch_is_an_error_not_a_silent_pass() { + let square = Image { + rgba: vec![0; 16], + width: 2, + height: 2, + }; + let strip = Image { + rgba: vec![0; 16], + width: 4, + height: 1, + }; + assert!(compare(&square, &strip).is_err()); + } + + #[test] + fn color_only_differences_are_counted_per_pixel() { + let left = Image { + rgba: vec![0, 0, 0, 255, 0, 0, 0, 255], + width: 2, + height: 1, + }; + let right = Image { + rgba: vec![10, 0, 0, 255, 0, 0, 0, 255], + width: 2, + height: 1, + }; + let diff = compare(&left, &right).unwrap(); + assert_eq!(diff.differing_pixels, 1); + assert_eq!(diff.max_channel_delta, 10); + assert!(diff.describe().contains("differing=1/2")); + } + + #[test] + fn png_roundtrip_through_disk() { + let path = temp_dir().join("roundtrip.png"); + let bytes = vec![200_u8; 4 * 3 * 2]; + write_rgba(&bytes, 3, 2, &path).unwrap(); + + let loaded = Image::load(&path).unwrap(); + assert_eq!((loaded.width, loaded.height), (3, 2)); + assert_eq!(loaded.rgba, bytes); + + let diff = diff_files(&path, &path).unwrap(); + assert!(diff.is_identical()); + } + + #[test] + fn shift_scan_finds_the_best_alignment() { + // 5x5 图,R 通道存唯一值;构造 right[y][x] = left[y+1][x+1](即把左图向左上挪 1px), + // 于是「left 与 right 在 dy=1,dx=1 时逐像素相等」——扫描应该把 (1,1) 找出来。 + let side = 5_i32; + let mut rgba = Vec::new(); + for y in 0..side { + for x in 0..side { + rgba.extend([(y * side + x) as u8, 0, 0, 255]); + } + } + let left = Image { + rgba, + width: side as u32, + height: side as u32, + }; + + let mut moved = vec![255_u8; (side * side * 4) as usize]; + for y in 0..side - 1 { + for x in 0..side - 1 { + let source = ((y + 1) * side + (x + 1)) as usize * 4; + let target = (y * side + x) as usize * 4; + moved[target..target + 4].copy_from_slice(&left.rgba[source..source + 4]); + } + } + let right = Image { + rgba: moved, + width: side as u32, + height: side as u32, + }; + + let diff = shift_scan(&left, &right, 2).unwrap(); + assert_eq!(diff.best_shift, (1, 1), "应扫出 1px 的位移"); + assert_eq!(diff.best_differing_pixels, 0, "对齐后应逐像素相等"); + assert!( + diff.differing_pixels > 0, + "不对齐时应该有差异(否则这条测试没有信息量)" + ); + + // 同一张图:最佳对齐就是 (0,0) + let same = shift_scan(&left, &left, 2).unwrap(); + assert_eq!(same.best_shift, (0, 0)); + assert_eq!(same.best_differing_pixels, 0); + } + + #[test] + fn wrong_byte_count_is_reported() { + let screenshot = window::Screenshot::new(vec![0_u8; 3], iced::Size::new(1, 1), 1.0); + let error = save(&screenshot, &temp_dir().join("unused.png")).unwrap_err(); + assert!(error.contains("字节数对不上"), "{error}"); + } +} diff --git a/crates/app-iced/src/shot/mod.rs b/crates/app-iced/src/shot/mod.rs new file mode 100644 index 0000000..1e2bb32 --- /dev/null +++ b/crates/app-iced/src/shot/mod.rs @@ -0,0 +1,100 @@ +//! 截图通道(D30 验收用):`iced::window::screenshot` 的真窗口出图 → PNG,以及两图逐像素对比。 +//! +//! ## 为什么用真窗口而不是 `iced_test::Emulator` +//! +//! 任务书首选路径①:真开窗截图覆盖了标题栏/DPI/窗口管理的真实行为,离屏通道不覆盖 +//! (M60 §Q6 的边界说明)。本机与 Windows CI 都有交互会话,所以走路径①。 +//! +//! ## 关键事实(M60 踩坑 Q3) +//! +//! `Screenshot` 的字节字段在 0.14 叫 **`rgba`**(0.13 是 `bytes`),`size` 是**物理像素**, +//! 另带 `scale_factor`(换算逻辑坐标用)。落盘用 `image::save_buffer` + `ExtendedColorType::Rgba8`。 +//! +//! ## 为什么自己带一个 diff +//! +//! D30 的硬项是「GPU 档与 CPU 档**几何逐像素一致**,只有颜色/材质不同」。CI 里不该假设有 +//! Python/PIL/ImageMagick,所以把「读两张 PNG → 数差异像素 → 给最大/平均通道差」做进本程序 +//! (`--diff a.png b.png`),复用同一个二进制。 + +use std::path::{Path, PathBuf}; + +use iced::window; + +use crate::theme; + +/// 把一帧截图写到 `path`(自动建父目录)。 +/// +/// # Errors +/// 目录建不出来、PNG 编码失败、字节数与尺寸对不上时返回人话(截图通道要能一眼看出哪一步断)。 +pub fn save(screenshot: &window::Screenshot, path: &Path) -> Result { + let width = screenshot.size.width; + let height = screenshot.size.height; + let bytes = screenshot.rgba.as_ref(); + + let expected = width as usize * height as usize * 4; + if bytes.len() != expected { + return Err(format!( + "截图字节数对不上:{width}x{height} 应该 {expected} B,实际 {} B", + bytes.len() + )); + } + + write_rgba(bytes, width, height, path)?; + + Ok(Summary { + width, + height, + scale_factor: screenshot.scale_factor, + path: path.to_path_buf(), + }) +} + +fn write_rgba(bytes: &[u8], width: u32, height: u32, path: &Path) -> Result<(), String> { + if let Some(parent) = path.parent() { + if !parent.as_os_str().is_empty() { + std::fs::create_dir_all(parent) + .map_err(|error| format!("建目录 {} 失败:{error}", parent.display()))?; + } + } + + image::save_buffer(path, bytes, width, height, image::ExtendedColorType::Rgba8) + .map_err(|error| format!("写 PNG {} 失败:{error}", path.display())) +} + +/// 一次截图的结果摘要(打日志与报告用)。 +#[derive(Debug, Clone, PartialEq)] +pub struct Summary { + /// 物理宽(px)。 + pub width: u32, + /// 物理高(px)。 + pub height: u32, + /// 窗口缩放因子(100% DPI = 1.0)。 + pub scale_factor: f32, + /// 落盘路径。 + pub path: PathBuf, +} + +impl Summary { + /// 一行日志。 + pub fn describe(&self) -> String { + format!( + "[shot] {}x{} scale={} -> {}", + self.width, + self.height, + self.scale_factor, + self.path.display() + ) + } +} + +mod diff; + +pub use diff::{compare, diff_files, diff_files_with_scan, shift_scan, Diff, Image}; + +/// 主题名 → 归档文件名片段(`visual-acceptance-protocol.md` §7 的命名)。 +pub fn flavor_slug(ui: &theme::Ui) -> &'static str { + match ui.flavor() { + unlucky_design::color::SchemeFlavor::Light => "light", + unlucky_design::color::SchemeFlavor::Dark => "dark", + } +} diff --git a/crates/app-iced/src/theme.rs b/crates/app-iced/src/theme.rs new file mode 100644 index 0000000..feaa65d --- /dev/null +++ b/crates/app-iced/src/theme.rs @@ -0,0 +1,334 @@ +//! 令牌 → iced 类型(`Color` / `Font` / `Border` / `Radius` / `Theme`)。 +//! +//! ## 边界(M61 `design-令牌约定.md` §0 的落地) +//! +//! 本模块是**唯一**允许把 `unlucky-design` 的值翻成 iced 类型的地方。组件层(`shell`) +//! 只调 [`Ui`] 的方法,绝不出现色值 / 字号 / 半径 / 间距字面量——`tests/no_hardcoded_tokens.rs` +//! 会扫 `src/**` 做守门。 +//! +//! ## 为什么不用 `iced::theme::Base` 自定义主题承载 49 个角色 +//! +//! `iced` 的 `Palette` 只有 6 个颜色(`background/text/primary/success/warning/danger`), +//! M3 有 49 个角色,塞不进去(M60 Q5 + M61 §9-0 的同一结论)。所以: +//! +//! - **真源**是本模块持有的 [`Scheme`](49 角色); +//! - iced 的 `Theme` 只用来「换名字」——`Theme::name()` 变了 iced 才会重算受主题影响的缓存 +//! (M61 §9 的最后一条)。`palette()` 只给 devtools 看,控件外观一律由各自的 `Style` 注入。 +//! +//! `success` / `warning` 在 M3 里**没有对应角色**(`design-令牌约定.md` §2 明写), +//! 这里按「语义最近的现成角色」映射:`success → Secondary`(不是「成功」的承诺)、 +//! `warning → Tertiary`(M3 把「提醒」放 Tertiary)。任何控件都不要去读这两个槽位。 + +use iced::border::Radius; +use iced::theme::Palette; +use iced::{Border, Color, Font, Theme}; + +use unlucky_design::color::{Role, Scheme, SchemeFlavor, StateLayer}; +use unlucky_design::elevation::MaterialLayer; +use unlucky_design::shape::ShapeScale; +use unlucky_design::spacing::{LayoutMetrics, Space}; +use unlucky_design::typography::{FontFamily, Platform, TypeRole, TypeStyle, Weight}; + +/// 全站描边宽度(逻辑 px @100% DPI)。rubric G7 要求全站 1px —— 令牌层的「一像素线单一真源」。 +/// +/// M69 起令牌层提供 `unlucky_design::stroke::HAIRLINE`(容器描边/分隔线/聚焦环共用), +/// 这里**再导出**而不是自己写 `1.0`:全仓只有令牌层一个定义点。 +pub use unlucky_design::stroke::HAIRLINE as STROKE_PX; + +/// 已经解析到系统的字体族名(`&'static str` 才能塞进 `iced::Font`)。 +/// +/// 由 `fonts` 模块按令牌栈 + 系统字体库求值,本模块只消费。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Families { + /// 正文族(`FontFamily::Text` 栈里第一个装机可用的)。 + pub text: &'static str, + /// 展示族(`FontFamily::Display`)。 + pub display: &'static str, + /// 等宽族(`FontFamily::Mono`,日志/版本号用)。 + pub mono: &'static str, +} + +/// 令牌求值器:一个 `Ui` 对应一套主题(浅或深)。 +/// +/// 深浅热切 = 重新 `Ui::new(flavor, families)` 并整树重绘;控件里不要缓存颜色。 +#[derive(Debug, Clone)] +pub struct Ui { + scheme: Scheme, + platform: Platform, + metrics: LayoutMetrics, + families: Families, +} + +impl Ui { + /// 按平台(由 `cfg!` 决定)与令牌基线方案求值。 + pub fn new(flavor: SchemeFlavor, families: Families) -> Self { + Self::with_platform(flavor, Platform::current(), families) + } + + /// 显式指定平台(跨平台核对与测试用)。 + pub fn with_platform(flavor: SchemeFlavor, platform: Platform, families: Families) -> Self { + Self { + scheme: Scheme::baseline(flavor), + platform, + metrics: LayoutMetrics::DESKTOP, + families, + } + } + + /// 当前深浅模式。 + pub fn flavor(&self) -> SchemeFlavor { + self.scheme.flavor() + } + + /// 当前平台(决定字体族与字重降级)。 + pub fn platform(&self) -> Platform { + self.platform + } + + /// 骨架尺寸(标题栏/导航/行高…)。 + pub fn metrics(&self) -> LayoutMetrics { + self.metrics + } + + /// 已解析的字体族名。 + pub fn families(&self) -> Families { + self.families + } + + /// 间距档位(4px 栅格)。 + pub fn space(&self, space: Space) -> f32 { + space.dp() + } + + /// M3 角色 → iced 颜色。 + pub fn color(&self, role: Role) -> Color { + to_iced(self.scheme.get(role)) + } + + /// 材质层(Canvas/Card/Overlay)→ 容器色。 + pub fn layer(&self, layer: MaterialLayer) -> Color { + self.color(layer.surface_role()) + } + + /// 状态层叠在容器色上,返回**不透明**结果。 + /// + /// 为什么不用带 alpha 的叠加:D27 要求 CPU 档不出现半透明,而「不透明结果」让 GPU/CPU + /// 两档的颜色也逐像素一致(只有材质/玻璃才允许两档不同)。 + pub fn state_over(&self, state: StateLayer, content: Role, container: Role) -> Color { + to_iced(state.over(self.scheme.get(content), self.scheme.get(container))) + } + + /// 圆角(令牌刻度);`side` 是控件在该轴上的边长(`ShapeScale::Full` 要按边长折算)。 + pub fn radius(&self, shape: ShapeScale, side: f32) -> Radius { + Radius::new(shape.radius_px(side)) + } + + /// 固定档圆角(不需要边长)。`ShapeScale::Full` 必须走 [`Ui::radius`](它没有绝对值)。 + pub fn radius_dp(&self, shape: ShapeScale) -> Radius { + debug_assert!( + !matches!(shape, ShapeScale::Full), + "Full 需要边长,请用 Ui::radius(shape, side)" + ); + Radius::new(if matches!(shape, ShapeScale::Full) { + 0.0 + } else { + shape.dp() + }) + } + + /// 描边:`OutlineVariant` + [`STROKE_PX`],圆角走刻度(固定档)。 + pub fn outlined(&self, shape: ShapeScale) -> Border { + Border { + color: self.color(Role::OutlineVariant), + width: STROKE_PX, + radius: self.radius_dp(shape), + } + } + + /// baseline 排版档。 + pub fn style(&self, role: TypeRole) -> TypeStyle { + role.style() + } + + /// Expressive 强调档(官方值:字重升到 500/700)。 + pub fn emphasized(&self, role: TypeRole) -> TypeStyle { + role.emphasized() + } + + /// 某个排版档对应的 iced 字体(族 + 字重)。 + pub fn font_of(&self, style: TypeStyle) -> Font { + let family = match style.family { + FontFamily::Display => self.families.display, + FontFamily::Text => self.families.text, + FontFamily::Mono => self.families.mono, + }; + + Font { + family: iced::font::Family::Name(family), + weight: weight_of(style.weight), + ..Font::DEFAULT + } + } + + /// baseline 档的字体。 + pub fn font(&self, role: TypeRole) -> Font { + self.font_of(self.style(role)) + } + + /// 强调档的字体。 + pub fn emphasized_font(&self, role: TypeRole) -> Font { + self.font_of(self.emphasized(role)) + } + + /// iced 的 6 色 `Palette`(只给 devtools / 兜底控件用;M3 的 49 角色在 [`Ui::color`])。 + pub fn palette(&self) -> Palette { + Palette { + background: self.color(Role::Surface), + text: self.color(Role::OnSurface), + primary: self.color(Role::Primary), + success: self.color(Role::Secondary), + warning: self.color(Role::Tertiary), + danger: self.color(Role::Error), + } + } + + /// iced 主题对象:**名字带深浅**,切主题时 `Theme::name()` 变化会触发重算。 + pub fn theme(&self) -> Theme { + Theme::custom(self.name(), self.palette()) + } + + /// `Theme::name()` 的取值(稳定字符串,改名要先在协作文档登记)。 + pub fn name(&self) -> &'static str { + match self.flavor() { + SchemeFlavor::Light => "ull-m3-light", + SchemeFlavor::Dark => "ull-m3-dark", + } + } +} + +/// 令牌色(8 bit sRGB)→ iced 的 sRGB 分量。 +pub fn to_iced(color: unlucky_design::color::Color) -> Color { + Color::from_rgba8( + color.r, + color.g, + color.b, + f32::from(color.a) / f32::from(u8::MAX), + ) +} + +/// 令牌字重 → iced 字重。 +pub fn weight_of(weight: Weight) -> iced::font::Weight { + match weight { + Weight::Regular => iced::font::Weight::Normal, + Weight::Medium => iced::font::Weight::Medium, + Weight::SemiBold => iced::font::Weight::Semibold, + Weight::Bold => iced::font::Weight::Bold, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const FAMILIES: Families = Families { + text: "Segoe UI Variable Text", + display: "Segoe UI Variable Display", + mono: "Cascadia Mono", + }; + + fn ui(flavor: SchemeFlavor) -> Ui { + Ui::with_platform(flavor, Platform::Windows, FAMILIES) + } + + #[test] + fn theme_name_tracks_the_flavor() { + assert_eq!(ui(SchemeFlavor::Light).name(), "ull-m3-light"); + assert_eq!(ui(SchemeFlavor::Dark).name(), "ull-m3-dark"); + assert_ne!( + ui(SchemeFlavor::Light).theme().to_string(), + ui(SchemeFlavor::Dark).theme().to_string() + ); + } + + #[test] + fn canvas_card_overlay_follow_surface_containers() { + let ui = ui(SchemeFlavor::Light); + assert_eq!(ui.layer(MaterialLayer::Canvas), ui.color(Role::Surface)); + assert_eq!( + ui.layer(MaterialLayer::Card), + ui.color(Role::SurfaceContainerLow) + ); + assert_eq!( + ui.layer(MaterialLayer::Overlay), + ui.color(Role::SurfaceContainerHigh) + ); + } + + #[test] + fn radii_only_come_from_the_scale() { + let ui = ui(SchemeFlavor::Light); + // 卡片 Medium(12):边长够大时就是 12 + assert_eq!(ui.radius_dp(ShapeScale::Medium).top_left, 12.0); + assert_eq!(ui.radius_dp(ShapeScale::ExtraSmall).top_left, 4.0); + // 药丸 Full:按边长一半折算 + assert_eq!(ui.radius(ShapeScale::Full, 32.0).top_left, 16.0); + // 不许超过边长一半 + assert_eq!(ui.radius(ShapeScale::ExtraLarge, 20.0).top_left, 10.0); + } + + #[test] + fn outlines_are_one_pixel_and_use_outline_variant() { + let ui = ui(SchemeFlavor::Light); + let border = ui.outlined(ShapeScale::Medium); + assert_eq!(border.width, STROKE_PX); + assert_eq!(border.width, 1.0, "rubric G7:全站 1px"); + assert_eq!(border.color, ui.color(Role::OutlineVariant)); + assert_eq!(border.radius.top_left, 12.0); + } + + #[test] + fn emphasized_weights_are_the_official_ones() { + let ui = ui(SchemeFlavor::Light); + // title-small 的强调档是 700(官方 Emphasized) + assert_eq!( + ui.emphasized_font(TypeRole::TitleSmall).weight, + iced::font::Weight::Bold + ); + // baseline 正文是 400 + assert_eq!( + ui.font(TypeRole::BodyMedium).weight, + iced::font::Weight::Normal + ); + } + + #[test] + fn body_text_keeps_wcag_contrast_in_both_flavors() { + for flavor in [SchemeFlavor::Light, SchemeFlavor::Dark] { + let scheme = Scheme::baseline(flavor); + assert!( + scheme.contrast(Role::OnSurface, Role::Surface) >= 4.5, + "{flavor:?} 的正文对比度不够" + ); + assert!( + scheme.contrast(Role::OnSecondaryContainer, Role::SecondaryContainer) >= 4.5, + "{flavor:?} 的选中态药丸对比度不够" + ); + } + } + + #[test] + fn every_role_maps_to_an_iced_color() { + for role in Role::ALL { + let color = ui(SchemeFlavor::Dark).color(role); + assert!((0.0..=1.0).contains(&color.a), "{role:?} 的 alpha 越界"); + } + } + + #[test] + fn state_layers_are_opaque_so_cpu_and_gpu_match() { + let ui = ui(SchemeFlavor::Light); + let hovered = ui.state_over(StateLayer::Hover, Role::OnSurface, Role::Surface); + assert_eq!(hovered.a, 1.0, "状态层必须给出不透明结果"); + assert_ne!(hovered, ui.color(Role::Surface)); + } +} diff --git a/crates/app-iced/tests/no_hardcoded_tokens.rs b/crates/app-iced/tests/no_hardcoded_tokens.rs new file mode 100644 index 0000000..5c1edff --- /dev/null +++ b/crates/app-iced/tests/no_hardcoded_tokens.rs @@ -0,0 +1,351 @@ +//! 门禁:**组件层零硬编码**(色值 / 尺寸)与「不依赖 slint」。 +//! +//! 这三条测试会扫本 crate 的 `src/**` 与 `Cargo.toml`,故意写得「宁可错杀」: +//! 将来 reviewer 复跑 `cargo test -p unlucky-app-iced` 就能挡住「顺手写个 #RRGGBB」的提交。 +//! +//! 判定口径(与 `component-fidelity-rubric.md` T1/T2、`visual-acceptance-protocol.md` §1 对齐): +//! +//! - **色值字面量**:`#` + 6 位十六进制(且**至少含一个数字**,这样文档里的 `#RRGGBB` 占位不会被误杀) +//! 或 `Color::from_rgb(0.1, …)` / `color!(0x…)` 这类带数值参数的构造。 +//! - **令牌优先**:唯一允许的是 `theme.rs`(适配层),且它也只做 `token → iced` 的搬运。 +//! - **尺寸字面量**:`shell.rs` 里 `.width(12.0)` / `.height(34)` / `.size(22)` 一律不许出现 +//! (必须走 `LayoutMetrics` / `TypeStyle` / `Space`)。 + +use std::path::{Path, PathBuf}; + +/// 本 crate 根目录(编译期注入,换机器也成立)。 +fn manifest_dir() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) +} + +/// 递归收集 `dir` 下的 `.rs` 文件。 +fn rust_files(dir: &Path, collected: &mut Vec) { + let entries = + std::fs::read_dir(dir).unwrap_or_else(|error| panic!("读不动 {}:{error}", dir.display())); + + for entry in entries { + let path = entry.expect("目录项读失败").path(); + if path.is_dir() { + rust_files(&path, collected); + } else if path.extension().is_some_and(|ext| ext == "rs") { + collected.push(path); + } + } +} + +fn sources() -> Vec<(PathBuf, String)> { + let mut files = Vec::new(); + rust_files(&manifest_dir().join("src"), &mut files); + + assert!( + files.len() >= 8, + "只扫到 {} 个源文件:扫描逻辑坏了,这条门禁等于没跑", + files.len() + ); + + files + .into_iter() + .map(|path| { + let text = std::fs::read_to_string(&path) + .unwrap_or_else(|error| panic!("读不动 {}:{error}", path.display())); + (path, text) + }) + .collect() +} + +/// `#RRGGBB` / `#AARRGGBB` 形式的色值字面量(至少含一个数字,避开 `#RRGGBB` 这种占位写法)。 +fn hex_color_at(bytes: &[u8], start: usize) -> Option<&str> { + let digits = bytes.get(start + 1..start + 7)?; + let all_hex = digits + .iter() + .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_whitespace()); + let has_number = digits.iter().any(u8::is_ascii_digit); + + if all_hex && has_number { + // 后面不能再跟着十六进制字符(否则是更长的 token,不是 6 位色值) + let next = bytes.get(start + 7).copied(); + if next.is_none_or(|byte| !byte.is_ascii_hexdigit()) { + return Some("hex 色值字面量(#RRGGBB)"); + } + } + + None +} + +/// 带数值参数的取色构造(`Color::from_rgb(0.1, …)`、`Color::from_rgb8(103, 80, 164)`、`color!(0x12, …)`)。 +/// +/// **子串匹配要注意前后缀**:`from_rgb(` 匹配不到 `from_rgb8(`,所以 8 bit 那个必须单独列出来 +/// (reviewer 造样就是这么绕过去的)。 +const NUMERIC_COLOR_CALLS: [&str; 8] = [ + "Color::from_rgb(", + "Color::from_rgb8(", + "Color::from_rgba(", + "Color::from_rgba8(", + "Color::from_hex(", + "Color::rgba(", + "Color::rgb8(", + "color!(", +]; + +/// 取色构造后面是不是跟着数值字面量(允许 `Color::from_rgba8(color.r, …)` 这类搬运)。 +fn numeric_color_call(text: &str) -> Option<&'static str> { + for pattern in NUMERIC_COLOR_CALLS { + let mut from = 0; + while let Some(found) = text[from..].find(pattern) { + let after = from + found + pattern.len(); + let next = text[after..].trim_start().chars().next(); + + // 参数以数字或 `0x`/`#` 开头才算字面量;`Color::from_rgba8(color.r, …)` 是搬运,放行。 + if next.is_some_and(|ch| ch.is_ascii_digit() || ch == '#') { + return Some(pattern); + } + + from = after; + } + } + + None +} + +/// 结构体字面量取色:`Color { r: 0.4, g: 0.3, b: 0.6, a: 1.0 }`。 +/// +/// 判据:`Color {` 之后 64 个字符内同时出现 `r:` 与 `g:` 且有数字(`Color { a: 0.0, .. }` 这种 +/// 只改 alpha 的写法不会命中,因为它没有 `r:`)。 +fn color_struct_literal(text: &str) -> bool { + let mut from = 0; + while let Some(found) = text[from..].find("Color {") { + let after = from + found + "Color {".len(); + let window: String = text[after..].chars().take(64).collect(); + let has_channels = window.contains("r:") && window.contains("g:"); + let has_number = window.chars().any(|ch| ch.is_ascii_digit()); + + if has_channels && has_number { + return true; + } + + from = after; + } + + false +} + +#[test] +fn no_hardcoded_colors_in_sources() { + let mut offenders = Vec::new(); + + for (path, text) in sources() { + for (index, line) in text.lines().enumerate() { + let bytes = line.as_bytes(); + let mut position = 0; + + while let Some(found) = line[position..].find('#') { + let at = position + found; + if let Some(kind) = hex_color_at(bytes, at) { + offenders.push(format!( + "{}:{} {line:?} -> {kind}", + path.display(), + index + 1 + )); + } + position = at + 1; + } + + if let Some(pattern) = numeric_color_call(line) { + offenders.push(format!( + "{}:{} {line:?} -> {pattern}", + path.display(), + index + 1 + )); + } + + if color_struct_literal(line) { + offenders.push(format!( + "{}:{} {line:?} -> Color {{ r: … }} 结构体字面量", + path.display(), + index + 1 + )); + } + } + } + + assert!( + offenders.is_empty(), + "组件层不许出现色值字面量(一律走 unlucky_design 的 Role):\n{}", + offenders.join("\n") + ); +} + +/// 判断某个布局调用后面是不是直接跟了尺寸字面量;`0`(= 无内边距)放行。 +/// +/// iced 里尺寸经常被包一层:`.width(Length::Fixed(123.0))`、`.padding(Padding::from([12.0, 8.0]))`、 +/// `.size(Size::new(10.0, 10.0))` —— 所以遇到这些包装要**往里剥一层**再判。 +fn size_literal(argument: &str) -> Option { + let mut rest = argument.trim_start(); + + for wrapper in ["Length::Fixed(", "Padding::from(", "Size::new("] { + if let Some(after) = rest.strip_prefix(wrapper) { + rest = after.trim_start().trim_start_matches(['[', '(']); + break; + } + } + + let number: String = rest + .trim_start() + .chars() + .take_while(|ch| ch.is_ascii_digit() || *ch == '.') + .collect(); + + if number.is_empty() || number.trim_end_matches('.').parse::() == Ok(0.0) { + return None; + } + + Some(number) +} + +#[test] +fn shell_takes_every_size_from_tokens() { + let mut files = Vec::new(); + rust_files(&manifest_dir().join("src").join("shell"), &mut files); + assert!( + files.len() >= 2, + "shell 模块至少有两个文件(mod.rs + nav.rs),实际扫到 {}", + files.len() + ); + + let mut offenders = Vec::new(); + + for path in files { + let text = std::fs::read_to_string(&path).expect("读 shell 源文件"); + + for (index, line) in text.lines().enumerate() { + // 文档注释是散文不是代码:里面写 `.width(24)` 是在解释坑,不该判违规 + if line.trim_start().starts_with("//") { + continue; + } + + for pattern in [".width(", ".height(", ".size(", ".spacing(", ".padding("] { + let mut from = 0; + while let Some(found) = line[from..].find(pattern) { + let after = from + found + pattern.len(); + if let Some(number) = size_literal(&line[after..]) { + offenders.push(format!( + "{}:{} {line:?} -> {pattern} 后面直接写了数字 {number}", + path.display(), + index + 1 + )); + } + from = after; + } + } + } + } + + assert!( + offenders.is_empty(), + "骨架尺寸必须来自 LayoutMetrics / TypeStyle / Space:\n{}", + offenders.join("\n") + ); +} + +/// 依赖行里出现 `slint` 就算违规(注释行放过:说明并存期要提到 slint)。 +fn manifest_has_slint_dependency(manifest: &str) -> Vec { + manifest + .lines() + .enumerate() + .filter(|(_, line)| { + let trimmed = line.trim_start(); + !trimmed.starts_with('#') && trimmed.contains("slint") + }) + .map(|(index, line)| format!("Cargo.toml:{} {line}", index + 1)) + .collect() +} + +#[test] +fn this_crate_never_depends_on_slint() { + let manifest = + std::fs::read_to_string(manifest_dir().join("Cargo.toml")).expect("读 Cargo.toml"); + + let offenders = manifest_has_slint_dependency(&manifest); + assert!( + offenders.is_empty(), + "P4 的 iced 版 crate 不许依赖 slint(D26):\n{}", + offenders.join("\n") + ); + + for (path, text) in sources() { + for (index, line) in text.lines().enumerate() { + // 只查真代码里的 use/路径,文档里提 slint 是允许的(要解释并存期) + let code = line.trim_start(); + let is_import = code.starts_with("use ") || code.starts_with("extern crate"); + if is_import && code.contains("slint") { + panic!("{}:{} 引入了 slint:{line}", path.display(), index + 1); + } + } + } +} + +#[test] +fn the_gate_itself_catches_a_planted_violation() { + // 自检:把违规样本喂给同一套判定,必须被抓住(否则门禁是空转)。 + // **reviewer r1 造样验证过的 5 个漏网样本逐条钉在这里**,退化成「放行」立刻红。 + + // 1. `Color::from_rgb8(103, 80, 164)` —— 8bit 版曾因 `from_rgb(` 匹配不到而漏 + assert!(numeric_color_call("Color::from_rgb8(103, 80, 164)").is_some()); + // 2. `iced::Color::from_rgb8(...)` —— 带路径前缀的写法同样要拦 + assert!(numeric_color_call("iced::Color::from_rgb8(103, 80, 164)").is_some()); + // 3. `Color { r: 0.4, g: 0.3, b: 0.6, a: 1.0 }` —— 结构体字面量 + assert!(color_struct_literal( + "Color { r: 0.4, g: 0.3, b: 0.6, a: 1.0 }" + )); + // 4. `.width(Length::Fixed(123.0))` —— 尺寸被 iced 包装一层 + assert_eq!( + size_literal("Length::Fixed(123.0))").as_deref(), + Some("123.0") + ); + // 5. `.padding(Padding::from([12.0, 8.0]))` —— 数组字面量 + assert_eq!( + size_literal("Padding::from([12.0, 8.0]))").as_deref(), + Some("12.0") + ); + + // 原有规则不能退化 + assert!(hex_color_at(b" color: #6750A4,", 11).is_some()); + assert!(hex_color_at(b"// placeholder #RRGGBB here", 15).is_none()); + assert!(numeric_color_call("Color::from_rgb(0.1, 0.2, 0.3)").is_some()); + assert!(numeric_color_call("Color::from_rgba8(color.r, color.g, color.b, color.a)").is_none()); + assert!(numeric_color_call("color!(0x5865F2)").is_some()); + assert!(size_literal("40.0)").is_some()); + assert!(size_literal("0)").is_none()); + assert!(size_literal("metrics.titlebar_height)").is_none()); + assert!(size_literal("Length::Fixed(thickness))").is_none()); + assert!(size_literal("Padding::from([0.0, ui.space(Space::X4)]))").is_none()); + assert!(!color_struct_literal( + "Color::from_rgba8(color.r, color.g, color.b, 1.0)" + )); + assert!(!color_struct_literal("let color = Color { a: 0.5 };")); + assert!(!manifest_has_slint_dependency("slint = \"1.18\"").is_empty()); + assert!(manifest_has_slint_dependency("# 不得依赖 slint").is_empty()); +} + +/// 渲染层回归守门(视觉复审 P2-1 的教训):`indicator_matches_the_reference_56x32` 只测纯函数, +/// 门禁全绿但图上的药丸实际是 88×32 —— 因为 `.center_x(Length::Fill)` 会把容器 width 覆盖成 Fill。 +/// 这里直接扫 `nav.rs`:药丸容器**不许**出现 `center_x(` / `center_y(`。 +#[test] +fn pill_never_overrides_its_size_with_fill() { + let nav = manifest_dir().join("src").join("shell").join("nav.rs"); + let text = std::fs::read_to_string(&nav).expect("读 nav.rs"); + + for (index, line) in text.lines().enumerate() { + let code = line.trim_start(); + if code.starts_with("//") { + continue; + } + for pattern in ["center_x(", "center_y("] { + assert!( + !code.contains(pattern), + "nav.rs:{} 用了 {pattern}——`center_*(Length::Fill)` 会把容器尺寸改成 Fill, 药丸会被父级宽度覆盖(视觉复审 P2-1)。居中请用父级 `align_x`:{line}", + index + 1 + ); + } + } +}