diff --git a/.github/workflows/build-packages.yml b/.github/workflows/build-packages.yml index f9b62e10..6699738d 100644 --- a/.github/workflows/build-packages.yml +++ b/.github/workflows/build-packages.yml @@ -30,6 +30,7 @@ jobs: libpango1.0-dev \ libgtk-3-dev \ libgtk-4-dev \ + libadwaita-1-dev \ meson \ ninja-build \ wayland-protocols \ diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 675c5a63..f5b58292 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,6 +34,7 @@ jobs: libudev-dev \ libgtk-3-dev \ libgtk-4-dev \ + libadwaita-1-dev \ meson \ ninja-build \ wayland-protocols \ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6122e442..3f758c3d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -19,7 +19,7 @@ The repository is one Cargo workspace using Rust 1.95 and edition 2024: - the root `wayscriber` package owns the overlay, daemon, CLI, shared domain/config/session code, rendering, and integration tests; -- `wayscriber-configurator` is the Iced GUI package under `configurator/`; +- `wayscriber-configurator` is the GTK4/libadwaita (Relm4) GUI package under `configurator/`; - root `Cargo.lock` is the single lockfile for both packages. The workspace default member is only the root package. Use `-p wayscriber-configurator` or diff --git a/Cargo.lock b/Cargo.lock index 2a44f864..9e694ba0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,84 +2,18 @@ # It is not intended for manual editing. version = 4 -[[package]] -name = "ab_glyph" -version = "0.2.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "01c0457472c38ea5bd1c3b5ada5e368271cb550be7a4ca4a0b4634e9913f6cc2" -dependencies = [ - "ab_glyph_rasterizer", - "owned_ttf_parser", -] - -[[package]] -name = "ab_glyph_rasterizer" -version = "0.1.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" - [[package]] name = "adler2" version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" -[[package]] -name = "ahash" -version = "0.8.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" -dependencies = [ - "cfg-if", - "getrandom 0.3.4", - "once_cell", - "version_check", - "zerocopy", -] - -[[package]] -name = "android-activity" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f2a1bb052857d5dd49572219344a7332b31b76405648eabac5bc68978251bcd" -dependencies = [ - "android-properties", - "bitflags 2.13.1", - "cc", - "jni", - "libc", - "log", - "ndk", - "ndk-context", - "ndk-sys", - "num_enum", - "thiserror 2.0.19", -] - -[[package]] -name = "android-properties" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc7eb209b1518d6bb87b283c20095f5228ecda460da70b44f0802523dea6da04" - [[package]] name = "anyhow" version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" -[[package]] -name = "arrayref" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" - -[[package]] -name = "arrayvec" -version = "0.7.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" - [[package]] name = "async-broadcast" version = "0.7.2" @@ -114,39 +48,18 @@ dependencies = [ "syn 3.0.3", ] -[[package]] -name = "atomic-waker" -version = "1.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" - [[package]] name = "autocfg" version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" -[[package]] -name = "bitflags" -version = "1.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" - [[package]] name = "bitflags" version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" -[[package]] -name = "block2" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c132eebf10f5cad5289222520a4a058514204aed6d791f1cf4fe8088b82d15f" -dependencies = [ - "objc2 0.5.2", -] - [[package]] name = "bumpalo" version = "3.20.3" @@ -185,7 +98,7 @@ version = "0.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5cc8d9aa793480744cd9a0524fef1a2e197d9eaa0f739cde19d16aba530dcb95" dependencies = [ - "bitflags 2.13.1", + "bitflags", "cairo-sys-rs", "glib", "libc", @@ -202,53 +115,27 @@ dependencies = [ "system-deps", ] -[[package]] -name = "calloop" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b99da2f8558ca23c71f4fd15dc57c906239752dd27ff3c00a1d56b685b7cbfec" -dependencies = [ - "bitflags 2.13.1", - "log", - "polling", - "rustix 0.38.44", - "slab", - "thiserror 1.0.69", -] - [[package]] name = "calloop" version = "0.14.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4dbf9978365bac10f54d1d4b04f7ce4427e51f71d61f2fe15e3fed5166474df7" dependencies = [ - "bitflags 2.13.1", + "bitflags", "polling", - "rustix 1.1.4", + "rustix", "slab", "tracing", ] -[[package]] -name = "calloop-wayland-source" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95a66a987056935f7efce4ab5668920b5d0dac4a7c99991a67395f13702ddd20" -dependencies = [ - "calloop 0.13.0", - "rustix 0.38.44", - "wayland-backend", - "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.4", - "rustix 1.1.4", + "calloop", + "rustix", "wayland-backend", "wayland-client", ] @@ -260,8 +147,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5add81bb678e6cb321aff7fa0dc7689ad82b112dbc032cea19f91d6b8e3582b9" dependencies = [ "find-msvc-tools", - "jobserver", - "libc", "shlex", ] @@ -281,51 +166,6 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" -[[package]] -name = "cfg_aliases" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" - -[[package]] -name = "clipboard-win" -version = "5.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4" -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.2", - "objc2-app-kit", - "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 = "combine" -version = "4.6.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" -dependencies = [ - "bytes", - "memchr", -] - [[package]] name = "concurrent-queue" version = "2.5.0" @@ -335,79 +175,6 @@ dependencies = [ "crossbeam-utils", ] -[[package]] -name = "core-foundation" -version = "0.9.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" -dependencies = [ - "core-foundation-sys", - "libc", -] - -[[package]] -name = "core-foundation-sys" -version = "0.8.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" - -[[package]] -name = "core-graphics" -version = "0.23.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081" -dependencies = [ - "bitflags 1.3.2", - "core-foundation", - "core-graphics-types", - "foreign-types", - "libc", -] - -[[package]] -name = "core-graphics-types" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf" -dependencies = [ - "bitflags 1.3.2", - "core-foundation", - "libc", -] - -[[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.1", - "fontdb", - "harfrust", - "linebender_resource_handle", - "log", - "rangemap", - "rustc-hash 1.1.0", - "self_cell", - "skrifa 0.37.0", - "smol_str", - "swash", - "sys-locale", - "unicode-bidi", - "unicode-linebreak", - "unicode-script", - "unicode-segmentation", -] - [[package]] name = "crc32fast" version = "1.5.0" @@ -423,55 +190,18 @@ version = "0.8.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" -[[package]] -name = "crunchy" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" - [[package]] name = "cursor-icon" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" -[[package]] -name = "dispatch" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b" - -[[package]] -name = "dispatch2" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" -dependencies = [ - "bitflags 2.13.1", - "objc2 0.6.4", -] - -[[package]] -name = "dlib" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a" -dependencies = [ - "libloading", -] - [[package]] name = "downcast-rs" version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" -[[package]] -name = "dpi" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" - [[package]] name = "dyn-clone" version = "1.0.20" @@ -521,12 +251,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "error-code" -version = "3.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" - [[package]] name = "event-listener" version = "5.4.2" @@ -552,6 +276,9 @@ name = "fastrand" version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" +dependencies = [ + "getrandom 0.4.3", +] [[package]] name = "fdeflate" @@ -589,73 +316,26 @@ dependencies = [ ] [[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.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0a7299a780854a6d391be2ae1c8521c9368471b559dbfd6a8dbd9f407eaff100" -dependencies = [ - "bytemuck", -] - -[[package]] -name = "fontconfig-parser" -version = "0.5.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbc773e24e02d4ddd8395fd30dc147524273a83e54e0f312d986ea30de5f5646" -dependencies = [ - "roxmltree", -] - -[[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 = "foreign-types" -version = "0.5.0" +name = "flume" +version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +checksum = "5e139bc46ca777eb5efaf62df0ab8cc5fd400866427e56c68b22e414e53bd3be" dependencies = [ - "foreign-types-macros", - "foreign-types-shared", + "fastrand", + "futures-core", + "futures-sink", + "spin", ] [[package]] -name = "foreign-types-macros" -version = "0.2.4" +name = "fragile" +version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea5190182e6915eb873ddbc16e23b711b6eb1f9c00a0d0a3a91b5f6228475225" +checksum = "8878864ba14bb86e818a412bfd6f18f9eabd4ec0f008a28e8f7eb61db532fcf9" dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.3", + "futures-core", ] -[[package]] -name = "foreign-types-shared" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" - [[package]] name = "futures" version = "0.3.33" @@ -834,8 +514,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi 6.0.0", + "wasm-bindgen", ] [[package]] @@ -888,19 +570,13 @@ dependencies = [ "xml-rs", ] -[[package]] -name = "glam" -version = "0.25.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "151665d9be52f9bb40fc7966565d39666f2d1e69233571b71b87791c7e0528b3" - [[package]] name = "glib" version = "0.22.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ddbcf514bd1881fc1b960e4e52b4e82873f4da3bceddbd58d42827b508888100" dependencies = [ - "bitflags 2.13.1", + "bitflags", "futures-channel", "futures-core", "futures-executor", @@ -1027,7 +703,7 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4069987ff4793699511a251028cc336b438e46565b463f111250148d574752a" dependencies = [ - "bitflags 2.13.1", + "bitflags", "gdk4", "glib", "glib-sys", @@ -1080,30 +756,6 @@ dependencies = [ "system-deps", ] -[[package]] -name = "half" -version = "2.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" -dependencies = [ - "cfg-if", - "crunchy", - "zerocopy", -] - -[[package]] -name = "harfrust" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92c020db12c71d8a12a3fe7607873cade3a01a6287e29d540c8723276221b9d8" -dependencies = [ - "bitflags 2.13.1", - "bytemuck", - "core_maths", - "read-fonts 0.35.0", - "smallvec", -] - [[package]] name = "hashbrown" version = "0.17.1" @@ -1135,296 +787,65 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" [[package]] -name = "iced" -version = "0.14.0" +name = "indexmap" +version = "2.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "000e01026c93ba643f8357a3db3ada0e6555265a377f6f9291c472f6dd701fb3" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ - "iced_core", - "iced_debug", - "iced_futures", - "iced_renderer", - "iced_runtime", - "iced_widget", - "iced_winit", - "thiserror 2.0.19", + "equivalent", + "hashbrown", ] [[package]] -name = "iced_core" -version = "0.14.0" +name = "input" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91ab1937d699403e7e69252ae743a902bcee9f4ab2052cc4c9a46fcf34729d85" +checksum = "f9793345a65d71317763a33066b5d8351f8760dde8d4930fe9e39b5f14a7959d" dependencies = [ - "bitflags 2.13.1", - "bytes", - "glam", - "lilt", + "bitflags", + "input-sys", + "libc", "log", - "num-traits", - "rustc-hash 2.1.3", - "smol_str", - "thiserror 2.0.19", - "web-time", + "udev", ] [[package]] -name = "iced_debug" -version = "0.14.0" +name = "input-sys" +version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25035ab0215a620e53f4103e36fc4e59a1fb2817e4bfc38a30ad27b4202ea0be" -dependencies = [ - "iced_core", - "iced_futures", - "log", -] +checksum = "36eee07d8e02bd95bf52b2e642cf13d33701b94c6e4b04fbf1d1fb07e9cb19e7" [[package]] -name = "iced_futures" -version = "0.14.0" +name = "io-lifetimes" +version = "1.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c0c85ccad42dfbec7293c36c018af0ea0dbcc52d137a4a9a0b0f6822a3fdf0a" +checksum = "eae7b9aee968036d54dce06cebaefd919e4472e753296daccd6d344e3e2df0c2" dependencies = [ - "futures", - "iced_core", - "log", - "rustc-hash 2.1.3", - "tokio", - "wasm-bindgen-futures", - "wasmtimer", + "hermit-abi 0.3.9", + "libc", + "windows-sys 0.48.0", ] [[package]] -name = "iced_graphics" -version = "0.14.0" +name = "itoa" +version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "234ca1c2cec4155055f68fa5fad1b5242c496ac8238d80a259bca382fb44a102" -dependencies = [ - "bitflags 2.13.1", - "bytemuck", - "cosmic-text", - "half", - "iced_core", - "iced_futures", - "log", - "raw-window-handle", - "rustc-hash 2.1.3", - "thiserror 2.0.19", - "unicode-segmentation", -] +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] -name = "iced_program" -version = "0.14.0" +name = "js-sys" +version = "0.3.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6dfafec2947cda688d8eb00dac337ba11aa60f9ef6335aed343e189d26e4a673" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" dependencies = [ - "iced_graphics", - "iced_runtime", + "cfg-if", + "futures-util", + "wasm-bindgen", ] [[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", - "log", - "thiserror 2.0.19", -] - -[[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.19", -] - -[[package]] -name = "iced_tiny_skia" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe0acf8b75a3bc914aff5f2329fdffc1b36eeaea29dda0e4bd232f1c62e9cc3d" -dependencies = [ - "bytemuck", - "cosmic-text", - "iced_debug", - "iced_graphics", - "kurbo", - "log", - "rustc-hash 2.1.3", - "softbuffer", - "tiny-skia", -] - -[[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.19", - "unicode-segmentation", -] - -[[package]] -name = "iced_winit" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b7dbedc47562d1de3b9707d939f678b88c382004b7ab5a18f7a7dd723162d75" -dependencies = [ - "iced_debug", - "iced_program", - "log", - "rustc-hash 2.1.3", - "thiserror 2.0.19", - "tracing", - "wasm-bindgen-futures", - "web-sys", - "window_clipboard", - "winit", -] - -[[package]] -name = "indexmap" -version = "2.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" -dependencies = [ - "equivalent", - "hashbrown", -] - -[[package]] -name = "input" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9793345a65d71317763a33066b5d8351f8760dde8d4930fe9e39b5f14a7959d" -dependencies = [ - "bitflags 2.13.1", - "input-sys", - "libc", - "log", - "udev", -] - -[[package]] -name = "input-sys" -version = "1.19.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "36eee07d8e02bd95bf52b2e642cf13d33701b94c6e4b04fbf1d1fb07e9cb19e7" - -[[package]] -name = "io-lifetimes" -version = "1.0.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eae7b9aee968036d54dce06cebaefd919e4472e753296daccd6d344e3e2df0c2" -dependencies = [ - "hermit-abi 0.3.9", - "libc", - "windows-sys 0.48.0", -] - -[[package]] -name = "itoa" -version = "1.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" - -[[package]] -name = "jni" -version = "0.22.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" -dependencies = [ - "cfg-if", - "combine", - "jni-macros", - "jni-sys 0.4.1", - "log", - "simd_cesu8", - "thiserror 2.0.19", - "walkdir", - "windows-link", -] - -[[package]] -name = "jni-macros" -version = "0.22.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" -dependencies = [ - "proc-macro2", - "quote", - "rustc_version", - "simd_cesu8", - "syn 2.0.119", -] - -[[package]] -name = "jni-sys" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" -dependencies = [ - "jni-sys 0.4.1", -] - -[[package]] -name = "jni-sys" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" -dependencies = [ - "jni-sys-macros", -] - -[[package]] -name = "jni-sys-macros" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" -dependencies = [ - "quote", - "syn 2.0.119", -] - -[[package]] -name = "jobserver" -version = "0.1.35" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" -dependencies = [ - "getrandom 0.4.3", - "libc", -] - -[[package]] -name = "js-sys" -version = "0.3.103" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" -dependencies = [ - "cfg-if", - "futures-util", - "wasm-bindgen", -] - -[[package]] -name = "khronos_api" -version = "3.1.0" +name = "khronos_api" +version = "3.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" @@ -1442,48 +863,40 @@ dependencies = [ ] [[package]] -name = "kurbo" -version = "0.10.4" +name = "libadwaita" +version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1618d4ebd923e97d67e7cd363d80aef35fe961005cbbbb3d2dad8bdd1bc63440" +checksum = "85b9900e67182a4b5b1f157b448d94f0715c8b9770cce21cf000801917f53bfa" dependencies = [ - "arrayvec", - "smallvec", + "gdk4", + "gio", + "glib", + "gtk4", + "libadwaita-sys", + "pango", ] [[package]] -name = "libc" -version = "0.2.189" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" - -[[package]] -name = "libloading" -version = "0.8.9" +name = "libadwaita-sys" +version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +checksum = "28d3c27642b389852aa99341bd4a4c19ec6f8a2b63ebdd7f5ba1952198079ccd" dependencies = [ - "cfg-if", - "windows-link", + "gdk4-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "gtk4-sys", + "libc", + "pango-sys", + "system-deps", ] [[package]] -name = "libm" -version = "0.2.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" - -[[package]] -name = "libredox" -version = "0.1.18" +name = "libc" +version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652" -dependencies = [ - "bitflags 2.13.1", - "libc", - "plain", - "redox_syscall 0.9.1", -] +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" [[package]] name = "libudev-sys" @@ -1495,27 +908,6 @@ 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" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4a5ff6bcca6c4867b1c4fd4ef63e4db7436ef363e0ad7531d1558856bae64f4" - -[[package]] -name = "linux-raw-sys" -version = "0.4.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" - [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -1582,353 +974,12 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "ndk" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" -dependencies = [ - "bitflags 2.13.1", - "jni-sys 0.3.1", - "log", - "ndk-sys", - "num_enum", - "raw-window-handle", - "thiserror 1.0.69", -] - -[[package]] -name = "ndk-context" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" - -[[package]] -name = "ndk-sys" -version = "0.6.0+11769913" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" -dependencies = [ - "jni-sys 0.3.1", -] - -[[package]] -name = "num-traits" -version = "0.2.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" -dependencies = [ - "autocfg", -] - -[[package]] -name = "num_enum" -version = "0.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" -dependencies = [ - "num_enum_derive", - "rustversion", -] - -[[package]] -name = "num_enum_derive" -version = "0.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" -dependencies = [ - "proc-macro-crate", - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "objc-sys" -version = "0.3.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdb91bdd390c7ce1a8607f35f3ca7151b65afc0ff5ff3b34fa350f7d7c7e4310" - -[[package]] -name = "objc2" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46a785d4eeff09c14c487497c162e92766fbb3e4059a71840cecc03d9a50b804" -dependencies = [ - "objc-sys", - "objc2-encode", -] - -[[package]] -name = "objc2" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" -dependencies = [ - "objc2-encode", -] - -[[package]] -name = "objc2-app-kit" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff" -dependencies = [ - "bitflags 2.13.1", - "block2", - "libc", - "objc2 0.5.2", - "objc2-core-data", - "objc2-core-image", - "objc2-foundation 0.2.2", - "objc2-quartz-core 0.2.2", -] - -[[package]] -name = "objc2-cloud-kit" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "74dd3b56391c7a0596a295029734d3c1c5e7e510a4cb30245f8221ccea96b009" -dependencies = [ - "bitflags 2.13.1", - "block2", - "objc2 0.5.2", - "objc2-core-location", - "objc2-foundation 0.2.2", -] - -[[package]] -name = "objc2-contacts" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5ff520e9c33812fd374d8deecef01d4a840e7b41862d849513de77e44aa4889" -dependencies = [ - "block2", - "objc2 0.5.2", - "objc2-foundation 0.2.2", -] - -[[package]] -name = "objc2-core-data" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef" -dependencies = [ - "bitflags 2.13.1", - "block2", - "objc2 0.5.2", - "objc2-foundation 0.2.2", -] - -[[package]] -name = "objc2-core-foundation" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" -dependencies = [ - "bitflags 2.13.1", - "dispatch2", - "objc2 0.6.4", -] - -[[package]] -name = "objc2-core-graphics" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" -dependencies = [ - "bitflags 2.13.1", - "dispatch2", - "objc2 0.6.4", - "objc2-core-foundation", - "objc2-io-surface", -] - -[[package]] -name = "objc2-core-image" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80" -dependencies = [ - "block2", - "objc2 0.5.2", - "objc2-foundation 0.2.2", - "objc2-metal", -] - -[[package]] -name = "objc2-core-location" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "000cfee34e683244f284252ee206a27953279d370e309649dc3ee317b37e5781" -dependencies = [ - "block2", - "objc2 0.5.2", - "objc2-contacts", - "objc2-foundation 0.2.2", -] - -[[package]] -name = "objc2-encode" -version = "4.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" - -[[package]] -name = "objc2-foundation" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" -dependencies = [ - "bitflags 2.13.1", - "block2", - "dispatch", - "libc", - "objc2 0.5.2", -] - -[[package]] -name = "objc2-foundation" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" -dependencies = [ - "bitflags 2.13.1", - "objc2 0.6.4", - "objc2-core-foundation", -] - -[[package]] -name = "objc2-io-surface" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" -dependencies = [ - "bitflags 2.13.1", - "objc2 0.6.4", - "objc2-core-foundation", -] - -[[package]] -name = "objc2-link-presentation" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1a1ae721c5e35be65f01a03b6d2ac13a54cb4fa70d8a5da293d7b0020261398" -dependencies = [ - "block2", - "objc2 0.5.2", - "objc2-app-kit", - "objc2-foundation 0.2.2", -] - -[[package]] -name = "objc2-metal" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" -dependencies = [ - "bitflags 2.13.1", - "block2", - "objc2 0.5.2", - "objc2-foundation 0.2.2", -] - -[[package]] -name = "objc2-quartz-core" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" -dependencies = [ - "bitflags 2.13.1", - "block2", - "objc2 0.5.2", - "objc2-foundation 0.2.2", - "objc2-metal", -] - -[[package]] -name = "objc2-quartz-core" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" -dependencies = [ - "bitflags 2.13.1", - "objc2 0.6.4", - "objc2-core-foundation", - "objc2-foundation 0.3.2", -] - -[[package]] -name = "objc2-symbols" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0a684efe3dec1b305badae1a28f6555f6ddd3bb2c2267896782858d5a78404dc" -dependencies = [ - "objc2 0.5.2", - "objc2-foundation 0.2.2", -] - -[[package]] -name = "objc2-ui-kit" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8bb46798b20cd6b91cbd113524c490f1686f4c4e8f49502431415f3512e2b6f" -dependencies = [ - "bitflags 2.13.1", - "block2", - "objc2 0.5.2", - "objc2-cloud-kit", - "objc2-core-data", - "objc2-core-image", - "objc2-core-location", - "objc2-foundation 0.2.2", - "objc2-link-presentation", - "objc2-quartz-core 0.2.2", - "objc2-symbols", - "objc2-uniform-type-identifiers", - "objc2-user-notifications", -] - -[[package]] -name = "objc2-uniform-type-identifiers" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44fa5f9748dbfe1ca6c0b79ad20725a11eca7c2218bceb4b005cb1be26273bfe" -dependencies = [ - "block2", - "objc2 0.5.2", - "objc2-foundation 0.2.2", -] - -[[package]] -name = "objc2-user-notifications" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3" -dependencies = [ - "bitflags 2.13.1", - "block2", - "objc2 0.5.2", - "objc2-core-location", - "objc2-foundation 0.2.2", -] - [[package]] name = "once_cell" version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" -[[package]] -name = "orbclient" -version = "0.3.55" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5df339f526ea9a60e371768d50efc2f2508c7203290731565d1f7a6f71d21747" -dependencies = [ - "libc", - "libredox", -] - [[package]] name = "ordered-stream" version = "0.2.0" @@ -1939,15 +990,6 @@ dependencies = [ "pin-project-lite", ] -[[package]] -name = "owned_ttf_parser" -version = "0.25.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "36820e9051aca1014ddc75770aab4d68bc1e9e632f0f5627c4086bc216fb583b" -dependencies = [ - "ttf-parser", -] - [[package]] name = "pango" version = "0.22.8" @@ -2002,86 +1044,31 @@ version = "2.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" -[[package]] -name = "parking_lot" -version = "0.12.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" -dependencies = [ - "lock_api", - "parking_lot_core", -] - -[[package]] -name = "parking_lot_core" -version = "0.9.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" -dependencies = [ - "cfg-if", - "libc", - "redox_syscall 0.5.18", - "smallvec", - "windows-link", -] - [[package]] name = "pastey" version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4" -[[package]] -name = "pin-project" -version = "1.1.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" -dependencies = [ - "pin-project-internal", -] - -[[package]] -name = "pin-project-internal" -version = "1.1.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - [[package]] name = "pin-project-lite" version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" -[[package]] -name = "pin-utils" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" - [[package]] name = "pkg-config" version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" -[[package]] -name = "plain" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" - [[package]] name = "png" version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" dependencies = [ - "bitflags 2.13.1", + "bitflags", "crc32fast", "fdeflate", "flate2", @@ -2098,7 +1085,7 @@ dependencies = [ "concurrent-queue", "hermit-abi 0.5.2", "pin-project-lite", - "rustix 1.1.4", + "rustix", "windows-sys 0.61.2", ] @@ -2150,67 +1137,6 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" -[[package]] -name = "rangemap" -version = "1.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "973443cf09a9c8656b574a866ab68dfa19f0867d0340648c7d2f6a71b8a8ea68" - -[[package]] -name = "raw-window-handle" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" - -[[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" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "046a7d674daf459825b32f5062056d6882db0d2f5a479fbd76ccfc870ac18709" -dependencies = [ - "bytemuck", - "font-types 0.12.2", - "once_cell", -] - -[[package]] -name = "redox_syscall" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4722d768eff46b75989dd134e5c353f0d6296e5aaa3132e776cbdb56be7731aa" -dependencies = [ - "bitflags 1.3.2", -] - -[[package]] -name = "redox_syscall" -version = "0.5.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" -dependencies = [ - "bitflags 2.13.1", -] - -[[package]] -name = "redox_syscall" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07507be7b4a5f9f26eeb41eeaebb1f5a7ff29dfb29739facc21d35bf8b11c21e" -dependencies = [ - "bitflags 2.13.1", -] - [[package]] name = "ref-cast" version = "1.0.26" @@ -2232,22 +1158,39 @@ dependencies = [ ] [[package]] -name = "roxmltree" -version = "0.20.0" +name = "relm4" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" +checksum = "6420f090f0545e9ec9656469d139a4e1b66ff9c30b808fe2247892724f71a198" +dependencies = [ + "flume", + "fragile", + "futures", + "gtk4", + "libadwaita", + "once_cell", + "relm4-css", + "relm4-macros", + "tokio", + "tracing", +] [[package]] -name = "rustc-hash" -version = "1.1.0" +name = "relm4-css" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" +checksum = "f3b81d263f784b103c815afa29124486b59741eca069ce7a5999efb14f13c368" [[package]] -name = "rustc-hash" -version = "2.1.3" +name = "relm4-macros" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" +checksum = "36c9dbf50a60c82375e66b61d522c936b187a11b25c0a42e91c516326ad24a4f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] [[package]] name = "rustc_version" @@ -2258,29 +1201,16 @@ dependencies = [ "semver", ] -[[package]] -name = "rustix" -version = "0.38.44" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" -dependencies = [ - "bitflags 2.13.1", - "errno", - "libc", - "linux-raw-sys 0.4.15", - "windows-sys 0.59.0", -] - [[package]] name = "rustix" version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags 2.13.1", + "bitflags", "errno", "libc", - "linux-raw-sys 0.12.1", + "linux-raw-sys", "windows-sys 0.61.2", ] @@ -2290,15 +1220,6 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" -[[package]] -name = "same-file" -version = "1.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" -dependencies = [ - "winapi-util", -] - [[package]] name = "schemars" version = "1.2.2" @@ -2324,37 +1245,12 @@ dependencies = [ "syn 3.0.3", ] -[[package]] -name = "scoped-tls" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" - [[package]] name = "scopeguard" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" -[[package]] -name = "sctk-adwaita" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6277f0217056f77f1d8f49f2950ac6c278c0d607c45f5ee99328d792ede24ec" -dependencies = [ - "ab_glyph", - "log", - "memmap2", - "smithay-client-toolkit 0.19.2", - "tiny-skia", -] - -[[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" @@ -2467,42 +1363,6 @@ version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" -[[package]] -name = "simd_cesu8" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" -dependencies = [ - "rustc_version", - "simdutf8", -] - -[[package]] -name = "simdutf8" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" - -[[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]] -name = "skrifa" -version = "0.44.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "819ab7d62b1d3e72d9d9dea5650bac30424f9111364bb94928dbf5ecad1baa68" -dependencies = [ - "bytemuck", - "read-fonts 0.41.0", -] - [[package]] name = "slab" version = "0.4.12" @@ -2510,62 +1370,28 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] -name = "slotmap" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" -dependencies = [ - "version_check", -] - -[[package]] -name = "smallvec" -version = "1.15.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" - -[[package]] -name = "smithay-client-toolkit" -version = "0.19.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3457dea1f0eb631b4034d61d4d8c32074caa6cd1ab2d59f2327bd8461e2c0016" -dependencies = [ - "bitflags 2.13.1", - "calloop 0.13.0", - "calloop-wayland-source 0.3.0", - "cursor-icon", - "libc", - "log", - "memmap2", - "rustix 0.38.44", - "thiserror 1.0.69", - "wayland-backend", - "wayland-client", - "wayland-csd-frame", - "wayland-cursor", - "wayland-protocols", - "wayland-protocols-wlr", - "wayland-scanner", - "xkeysym", -] - -[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[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.1", + "bitflags", "bytemuck", - "calloop 0.14.4", - "calloop-wayland-source 0.4.1", + "calloop", + "calloop-wayland-source", "cursor-icon", "libc", "log", "memmap2", "pkg-config", - "rustix 1.1.4", - "thiserror 2.0.19", + "rustix", + "thiserror", "wayland-backend", "wayland-client", "wayland-csd-frame", @@ -2579,26 +1405,6 @@ dependencies = [ "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" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd538fb6910ac1099850255cf94a94df6551fbdd602454387d0adb2d1ca6dead" -dependencies = [ - "serde", -] - [[package]] name = "socket2" version = "0.6.5" @@ -2610,48 +1416,12 @@ dependencies = [ ] [[package]] -name = "softbuffer" -version = "0.4.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aac18da81ebbf05109ab275b157c22a653bb3c12cf884450179942f81bcbf6c3" -dependencies = [ - "bytemuck", - "fastrand", - "js-sys", - "memmap2", - "ndk", - "objc2 0.6.4", - "objc2-core-foundation", - "objc2-core-graphics", - "objc2-foundation 0.3.2", - "objc2-quartz-core 0.3.2", - "raw-window-handle", - "redox_syscall 0.5.18", - "rustix 1.1.4", - "tracing", - "wasm-bindgen", - "wayland-backend", - "wayland-client", - "wayland-sys", - "web-sys", - "windows-sys 0.61.2", -] - -[[package]] -name = "strict-num" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" - -[[package]] -name = "swash" -version = "0.2.10" +name = "spin" +version = "0.9.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c2499c2d826531388872b2268718aed907a39bd785ab0dcfe57fab26283f92e" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" dependencies = [ - "skrifa 0.44.0", - "yazi", - "zeno", + "lock_api", ] [[package]] @@ -2676,15 +1446,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "sys-locale" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8eab9a99a024a169fe8a903cf9d4a3b3601109bcc13bd9e3c6fff259138626c4" -dependencies = [ - "libc", -] - [[package]] name = "system-deps" version = "7.0.8" @@ -2713,37 +1474,17 @@ dependencies = [ "fastrand", "getrandom 0.4.3", "once_cell", - "rustix 1.1.4", + "rustix", "windows-sys 0.61.2", ] -[[package]] -name = "thiserror" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" -dependencies = [ - "thiserror-impl 1.0.69", -] - [[package]] name = "thiserror" version = "2.0.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" dependencies = [ - "thiserror-impl 2.0.19", -] - -[[package]] -name = "thiserror-impl" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", + "thiserror-impl", ] [[package]] @@ -2757,46 +1498,6 @@ dependencies = [ "syn 3.0.3", ] -[[package]] -name = "tiny-skia" -version = "0.11.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83d13394d44dae3207b52a326c0c85a8bf87f1541f23b0d143811088497b09ab" -dependencies = [ - "arrayref", - "arrayvec", - "bytemuck", - "cfg-if", - "log", - "tiny-skia-path", -] - -[[package]] -name = "tiny-skia-path" -version = "0.11.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c9e7fc0c2e86a30b117d0462aa261b72b7a99b7ebd7deb3a14ceda95c5bdc93" -dependencies = [ - "arrayref", - "bytemuck", - "strict-num", -] - -[[package]] -name = "tinyvec" -version = "1.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" -dependencies = [ - "tinyvec_macros", -] - -[[package]] -name = "tinyvec_macros" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" - [[package]] name = "tokio" version = "1.53.1" @@ -2909,15 +1610,6 @@ dependencies = [ "once_cell", ] -[[package]] -name = "ttf-parser" -version = "0.25.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" -dependencies = [ - "core_maths", -] - [[package]] name = "udev" version = "0.9.3" @@ -2941,30 +1633,12 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "unicode-bidi" -version = "0.3.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" - [[package]] name = "unicode-ident" version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" -[[package]] -name = "unicode-linebreak" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" - -[[package]] -name = "unicode-script" -version = "0.5.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "383ad40bb927465ec0ce7720e033cb4ca06912855fc35db31b5755d0de75b1ee" - [[package]] name = "unicode-segmentation" version = "1.13.3" @@ -2988,22 +1662,6 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e" -[[package]] -name = "version_check" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" - -[[package]] -name = "walkdir" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" -dependencies = [ - "same-file", - "winapi-util", -] - [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -3032,16 +1690,6 @@ dependencies = [ "wasm-bindgen-shared", ] -[[package]] -name = "wasm-bindgen-futures" -version = "0.4.76" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - [[package]] name = "wasm-bindgen-macro" version = "0.2.126" @@ -3074,20 +1722,6 @@ dependencies = [ "unicode-ident", ] -[[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.16" @@ -3096,8 +1730,7 @@ checksum = "016ccf01d1c58b6f8999612813e17c9b2390f7d70671428869913310f83f54b8" dependencies = [ "cc", "downcast-rs", - "rustix 1.1.4", - "scoped-tls", + "rustix", "smallvec", "wayland-sys", ] @@ -3108,8 +1741,8 @@ version = "0.31.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3c36a0f861ad76d0901f2800b46321410d9f73f2ea88aac0650d86c32688073" dependencies = [ - "bitflags 2.13.1", - "rustix 1.1.4", + "bitflags", + "rustix", "wayland-backend", "wayland-scanner", ] @@ -3120,7 +1753,7 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "625c5029dbd43d25e6aa9615e88b829a5cad13b2819c4ae129fdbb7c31ab4c7e" dependencies = [ - "bitflags 2.13.1", + "bitflags", "cursor-icon", "wayland-backend", ] @@ -3131,7 +1764,7 @@ version = "0.31.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4a52d18780be9b1314328a3de5f930b73d2200112e3849ca6cb11822793fb34d" dependencies = [ - "rustix 1.1.4", + "rustix", "wayland-client", "xcursor", ] @@ -3142,7 +1775,7 @@ version = "0.32.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "23d0c813de3daa2ed6520af85a3bd49b0e722a3078506899aa9686fea58dc4b6" dependencies = [ - "bitflags 2.13.1", + "bitflags", "wayland-backend", "wayland-client", "wayland-scanner", @@ -3154,7 +1787,7 @@ version = "20250721.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40a1f863128dcaaec790d7b4b396cc9b9a7a079e878e18c47e6c2d2c5a8dcbb1" dependencies = [ - "bitflags 2.13.1", + "bitflags", "wayland-backend", "wayland-client", "wayland-protocols", @@ -3167,20 +1800,7 @@ version = "0.3.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6e9567599ef23e09b8dad6e429e5738d4509dfc46b3b21f32841a304d16b29c8" dependencies = [ - "bitflags 2.13.1", - "wayland-backend", - "wayland-client", - "wayland-protocols", - "wayland-scanner", -] - -[[package]] -name = "wayland-protocols-plasma" -version = "0.3.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b6d8cf1eb2c1c31ed1f5643c88a6e53538129d4af80030c8cabd1f9fa884d91" -dependencies = [ - "bitflags 2.13.1", + "bitflags", "wayland-backend", "wayland-client", "wayland-protocols", @@ -3193,7 +1813,7 @@ version = "0.3.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234" dependencies = [ - "bitflags 2.13.1", + "bitflags", "wayland-backend", "wayland-client", "wayland-protocols", @@ -3217,9 +1837,6 @@ version = "0.31.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be" dependencies = [ - "dlib", - "log", - "once_cell", "pkg-config", ] @@ -3245,7 +1862,7 @@ dependencies = [ "serde", "serde_ignored", "serde_json", - "smithay-client-toolkit 0.20.0", + "smithay-client-toolkit", "tokio", "toml", "toml_edit", @@ -3263,53 +1880,13 @@ dependencies = [ name = "wayscriber-configurator" version = "0.9.23" dependencies = [ - "iced", + "gtk4", + "libadwaita", + "relm4", "tokio", "wayscriber", ] -[[package]] -name = "web-sys" -version = "0.3.103" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - -[[package]] -name = "web-time" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - -[[package]] -name = "winapi-util" -version = "0.1.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" -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", - "raw-window-handle", - "thiserror 2.0.19", -] - [[package]] name = "windows-link" version = "0.2.1" @@ -3322,25 +1899,7 @@ version = "0.48.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" dependencies = [ - "windows-targets 0.48.5", -] - -[[package]] -name = "windows-sys" -version = "0.52.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" -dependencies = [ - "windows-targets 0.52.6", -] - -[[package]] -name = "windows-sys" -version = "0.59.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" -dependencies = [ - "windows-targets 0.52.6", + "windows-targets", ] [[package]] @@ -3358,29 +1917,13 @@ version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" dependencies = [ - "windows_aarch64_gnullvm 0.48.5", - "windows_aarch64_msvc 0.48.5", - "windows_i686_gnu 0.48.5", - "windows_i686_msvc 0.48.5", - "windows_x86_64_gnu 0.48.5", - "windows_x86_64_gnullvm 0.48.5", - "windows_x86_64_msvc 0.48.5", -] - -[[package]] -name = "windows-targets" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" -dependencies = [ - "windows_aarch64_gnullvm 0.52.6", - "windows_aarch64_msvc 0.52.6", - "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm", - "windows_i686_msvc 0.52.6", - "windows_x86_64_gnu 0.52.6", - "windows_x86_64_gnullvm 0.52.6", - "windows_x86_64_msvc 0.52.6", + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", ] [[package]] @@ -3389,138 +1932,42 @@ version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" - [[package]] name = "windows_aarch64_msvc" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" -[[package]] -name = "windows_aarch64_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" - [[package]] name = "windows_i686_gnu" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" -[[package]] -name = "windows_i686_gnu" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" - -[[package]] -name = "windows_i686_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" - [[package]] name = "windows_i686_msvc" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" -[[package]] -name = "windows_i686_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" - [[package]] name = "windows_x86_64_gnu" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" -[[package]] -name = "windows_x86_64_gnu" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" - [[package]] name = "windows_x86_64_gnullvm" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" - [[package]] name = "windows_x86_64_msvc" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" -[[package]] -name = "windows_x86_64_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" - -[[package]] -name = "winit" -version = "0.30.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6755fa58a9f8350bd1e472d4c3fcc25f824ec358933bba33306d0b63df5978d" -dependencies = [ - "ahash", - "android-activity", - "atomic-waker", - "bitflags 2.13.1", - "block2", - "calloop 0.13.0", - "cfg_aliases", - "concurrent-queue", - "core-foundation", - "core-graphics", - "cursor-icon", - "dpi", - "js-sys", - "libc", - "memmap2", - "ndk", - "objc2 0.5.2", - "objc2-app-kit", - "objc2-foundation 0.2.2", - "objc2-ui-kit", - "orbclient", - "pin-project", - "raw-window-handle", - "redox_syscall 0.4.1", - "rustix 0.38.44", - "sctk-adwaita", - "smithay-client-toolkit 0.19.2", - "smol_str", - "tracing", - "unicode-segmentation", - "wasm-bindgen", - "wasm-bindgen-futures", - "wayland-backend", - "wayland-client", - "wayland-protocols", - "wayland-protocols-plasma", - "web-sys", - "web-time", - "windows-sys 0.52.0", - "xkbcommon-dl", -] - [[package]] name = "winnow" version = "1.0.4" @@ -3553,19 +2000,6 @@ dependencies = [ "xkeysym", ] -[[package]] -name = "xkbcommon-dl" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d039de8032a9a8856a6be89cea3e5d12fdd82306ab7c94d74e6deab2460651c5" -dependencies = [ - "bitflags 2.13.1", - "dlib", - "log", - "once_cell", - "xkeysym", -] - [[package]] name = "xkeysym" version = "0.2.1" @@ -3581,12 +2015,6 @@ version = "0.8.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f" -[[package]] -name = "yazi" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e01738255b5a16e78bbb83e7fbba0a1e7dd506905cfc53f4622d89015a03fbb5" - [[package]] name = "zbus" version = "5.18.0" @@ -3603,7 +2031,7 @@ dependencies = [ "hex", "libc", "ordered-stream", - "rustix 1.1.4", + "rustix", "serde", "serde_repr", "tokio", @@ -3643,32 +2071,6 @@ dependencies = [ "zvariant", ] -[[package]] -name = "zeno" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6df3dc4292935e51816d896edcd52aa30bc297907c26167fec31e2b0c6a32524" - -[[package]] -name = "zerocopy" -version = "0.8.55" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5a105cd7b140f6eeec8acff2ea38135d3cab283ada58540f629fe51e46696eb" -dependencies = [ - "zerocopy-derive", -] - -[[package]] -name = "zerocopy-derive" -version = "0.8.55" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fe976fb70c78cd64cccfe3a6fc142244e8a77b70959b30faf9d0ac37ee228eb" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - [[package]] name = "zmij" version = "1.0.23" diff --git a/configurator/Cargo.toml b/configurator/Cargo.toml index a7735c7a..2d626b08 100644 --- a/configurator/Cargo.toml +++ b/configurator/Cargo.toml @@ -10,8 +10,13 @@ workspace = true [dependencies] # The configurator edits config and service files; it does not call portal D-Bus APIs directly. wayscriber = { path = "..", default-features = false } -iced = { version = "0.14", default-features = false, features = ["tokio", "tiny-skia", "wayland"] } tokio = { version = "1.0", features = ["rt-multi-thread", "macros", "time", "sync"] } +relm4 = { version = "0.11", features = ["libadwaita"] } +# Feature floor pinned to the release runner's Ubuntu LTS: 24.04 ships +# libadwaita 1.5, and every API this crate uses exists by 1.4. Newer distro +# runtimes (Arch 1.9+) still apply their styling to the same binary. +libadwaita = { version = "0.9", features = ["v1_4"] } +gtk4 = { version = "0.11", features = ["v4_10"] } [features] default = ["tablet-input"] diff --git a/configurator/README.md b/configurator/README.md index d75b79b8..94826286 100644 --- a/configurator/README.md +++ b/configurator/README.md @@ -1,13 +1,13 @@ -# Wayscriber Configurator (Iced) +# Wayscriber Configurator (GTK4) -Native Rust desktop UI for editing `~/.config/wayscriber/config.toml`. The application is built on [`iced`](https://github.com/iced-rs/iced) and reuses the `wayscriber::Config` types directly, so validation and defaults match the CLI. It also retains the original TOML document so comments, ordering, and settings unknown to this build survive a save. +Native Rust desktop UI for editing `~/.config/wayscriber/config.toml`. The application is built on GTK4 and libadwaita through [Relm4](https://relm4.org) and reuses the `wayscriber::Config` types directly, so validation and defaults match the CLI. It also retains the original TOML document so comments, ordering, and settings unknown to this build survive a save. `config.toml` changes only through an explicit user edit action, never automatically. This program writes it when you press **Save**; the overlay writes it from three narrow editors — shortcut editing, preset slots, and the quick-color palette — each of which rewrites only its own key and backs the file up first. Nothing else in Wayscriber — daemon, tray, startup, shutdown, validation — ever changes it, so an incidental preference toggle applies to that run and sends you here for a durable change. ## Prerequisites - Rust toolchain 1.95 or newer. -- System dependencies required by `iced`'s Wayland `tiny-skia` renderer. +- System development packages for GTK 4 and libadwaita. ## Run It @@ -16,9 +16,9 @@ cd configurator cargo run ``` -The configurator uses Iced's software `tiny-skia` renderer on Wayland. It does -not compile the GPU renderer or portal D-Bus implementation into the -configurator binary. +The configurator renders through GTK 4 and libadwaita. It does not compile the +overlay's GPU renderer or the portal D-Bus implementation into the configurator +binary. The window loads the current config, lets you tweak values across the tabbed sections, and writes changes back through the guarded `ConfigDocument` save interface when you press Save. Loading, diff --git a/configurator/src/app/component.rs b/configurator/src/app/component.rs new file mode 100644 index 00000000..ca2eb382 --- /dev/null +++ b/configurator/src/app/component.rs @@ -0,0 +1,514 @@ +//! GTK4/libadwaita shell: the Relm4 root component. +//! +//! [`ConfiguratorApp`] stays the single-owner model it was under Iced; this +//! module owns everything GTK. `update` feeds messages through the +//! framework-free dispatch in [`super::update`] and runs the returned +//! [`Effect`]s as Relm4 commands, whose results come back as ordinary +//! messages through `update_cmd`. `update_view` is the one place widget +//! state is written: shell chrome directly, page rows through the bindings +//! the page builders registered ([`super::pages`]). + +use relm4::prelude::*; +use relm4::{adw, gtk}; + +use adw::prelude::*; + +use crate::messages::{CommandMessage, Message}; +use crate::models::{StartupRequest, TabId}; + +use super::effects::Effect; +use super::pages::{self, Binding}; +use super::search::AppSearchSummary; +use super::state::{ConfiguratorApp, StatusMessage}; +use super::{daemon_setup, io, session_catalog}; + +/// GApplication id. A valid dotted id is required by GLib; the window still +/// advertises this as its Wayland app-id, so compositor rules and the +/// `.desktop` `StartupWMClass` must match it. +const APP_ID: &str = "org.wayscriber.Configurator"; + +pub(crate) fn run(startup: StartupRequest) { + // Every launch is its own window, as it was under Iced: the overlay and + // the tray spawn `wayscriber-configurator --open ` and + // expect that destination to land. GApplication's default uniqueness + // would instead forward a bare activation to an already-running + // instance and drop the request on the floor. Concurrent windows are + // safe: the guarded `ConfigDocument` save revision arbitrates writes. + let application = adw::Application::builder() + .application_id(APP_ID) + .flags(gtk::gio::ApplicationFlags::NON_UNIQUE) + .build(); + let app = RelmApp::from_app(application); + // GTK must not parse our argv: `--open ` is ours and was + // consumed by `StartupRequest::from_args` already. + app.with_args(Vec::new()).run::(startup); +} + +pub(crate) struct AppWidgets { + window_title: adw::WindowTitle, + status_label: gtk::Label, + status_revealer: gtk::Revealer, + migration_revealer: gtk::Revealer, + migration_label: gtk::Label, + /// Last migration text rendered, so the label is only rewritten when the + /// offer actually changes. + migration_seen: String, + save_button: gtk::Button, + defaults_button: gtk::Button, + reload_button: gtk::Button, + sidebar_rows: Vec<(TabId, gtk::ListBoxRow)>, + sidebar: gtk::ListBox, + stack: gtk::Stack, + search_entry: gtk::SearchEntry, + /// Focus-request serial already honored, so one request grabs focus once + /// instead of on every refresh. + seen_focus_serial: u64, + bindings: Vec, +} + +impl Component for ConfiguratorApp { + type Init = StartupRequest; + type Input = Message; + type Output = (); + type CommandOutput = CommandMessage; + type Root = adw::ApplicationWindow; + type Widgets = AppWidgets; + + fn init_root() -> Self::Root { + adw::ApplicationWindow::builder() + .default_width(1000) + .default_height(680) + .build() + } + + fn init( + startup: Self::Init, + root: Self::Root, + sender: ComponentSender, + ) -> ComponentParts { + let (model, effects) = ConfiguratorApp::new_app_with_startup(startup); + for effect in effects { + spawn_effect(effect, &sender); + } + + // ---- Sidebar ---------------------------------------------------- + let search_entry = gtk::SearchEntry::builder() + .placeholder_text("Search settings") + .build(); + { + let sender = sender.clone(); + search_entry.connect_search_changed(move |entry| { + sender.input(Message::SearchChanged(entry.text().to_string())); + }); + } + { + let sender = sender.clone(); + search_entry.connect_stop_search(move |_| { + sender.input(Message::SearchCleared); + }); + } + + let sidebar = gtk::ListBox::builder() + .selection_mode(gtk::SelectionMode::Single) + .css_classes(["navigation-sidebar"]) + .build(); + let mut sidebar_rows = Vec::new(); + for tab in TabId::ALL { + let row = gtk::ListBoxRow::builder() + .child( + >k::Label::builder() + .label(tab.title()) + .halign(gtk::Align::Start) + .margin_top(8) + .margin_bottom(8) + .margin_start(6) + .margin_end(6) + .build(), + ) + .build(); + sidebar.append(&row); + sidebar_rows.push((tab, row)); + } + { + let sender = sender.clone(); + let rows = sidebar_rows.clone(); + sidebar.connect_row_selected(move |_, selected| { + let Some(selected) = selected else { + return; + }; + if let Some((tab, _)) = rows.iter().find(|(_, row)| row == selected) { + sender.input(Message::TabSelected(*tab)); + } + }); + } + + let sidebar_scroll = gtk::ScrolledWindow::builder() + .hscrollbar_policy(gtk::PolicyType::Never) + .vexpand(true) + .child(&sidebar) + .build(); + let sidebar_box = gtk::Box::new(gtk::Orientation::Vertical, 6); + sidebar_box.set_margin_top(6); + sidebar_box.set_margin_start(6); + sidebar_box.set_margin_end(6); + sidebar_box.append(&search_entry); + sidebar_box.append(&sidebar_scroll); + let sidebar_page = adw::NavigationPage::builder() + .title("Wayscriber") + .child(&sidebar_box) + .build(); + + // ---- Header actions --------------------------------------------- + let window_title = adw::WindowTitle::new("Wayscriber Configurator", ""); + let reload_button = gtk::Button::with_label("Reload"); + { + let sender = sender.clone(); + reload_button.connect_clicked(move |_| sender.input(Message::ReloadRequested)); + } + let defaults_button = gtk::Button::with_label("Defaults"); + { + let sender = sender.clone(); + defaults_button.connect_clicked(move |_| { + sender.input(Message::ResetToDefaultsRequested); + }); + } + let save_button = gtk::Button::with_label("Save"); + save_button.add_css_class("suggested-action"); + { + let sender = sender.clone(); + save_button.connect_clicked(move |_| sender.input(Message::SaveRequested)); + } + + let header = adw::HeaderBar::builder() + .title_widget(&window_title) + .build(); + header.pack_start(&reload_button); + header.pack_start(&defaults_button); + header.pack_end(&save_button); + + // ---- Status + migration strip ----------------------------------- + let status_label = gtk::Label::builder() + .wrap(true) + .xalign(0.0) + .selectable(true) + .margin_top(6) + .margin_bottom(6) + .margin_start(12) + .margin_end(12) + .build(); + let status_revealer = gtk::Revealer::builder().child(&status_label).build(); + + let migration_label = gtk::Label::builder().wrap(true).xalign(0.0).build(); + let migration_apply = gtk::Button::with_label("Apply Update"); + migration_apply.add_css_class("suggested-action"); + { + let sender = sender.clone(); + migration_apply.connect_clicked(move |_| { + sender.input(Message::MigrationApplyRequested); + }); + } + let migration_dismiss = gtk::Button::with_label("Dismiss"); + { + let sender = sender.clone(); + migration_dismiss.connect_clicked(move |_| { + sender.input(Message::MigrationDismissed); + }); + } + let migration_buttons = gtk::Box::new(gtk::Orientation::Horizontal, 6); + migration_buttons.append(&migration_apply); + migration_buttons.append(&migration_dismiss); + let migration_box = gtk::Box::new(gtk::Orientation::Vertical, 6); + migration_box.add_css_class("card"); + migration_box.set_margin_start(12); + migration_box.set_margin_end(12); + migration_box.set_margin_top(6); + migration_label.set_margin_top(8); + migration_label.set_margin_start(8); + migration_label.set_margin_end(8); + migration_box.append(&migration_label); + migration_buttons.set_margin_start(8); + migration_buttons.set_margin_bottom(8); + migration_box.append(&migration_buttons); + let migration_revealer = gtk::Revealer::builder().child(&migration_box).build(); + + // ---- Pages ------------------------------------------------------- + let stack = gtk::Stack::builder() + .transition_type(gtk::StackTransitionType::Crossfade) + .vexpand(true) + .build(); + let mut bindings: Vec = Vec::new(); + for (tab, built) in pages::build_all(&sender) { + stack.add_named(&built.widget, Some(pages::stack_name(tab))); + bindings.extend(built.bindings); + } + + // Sidebar visibility follows the search summary. + { + let rows = sidebar_rows.clone(); + bindings.push(Box::new( + move |_app: &ConfiguratorApp, summary: &AppSearchSummary| { + for (tab, row) in &rows { + let visible = !summary.is_active() || summary.tab(*tab).is_some(); + if row.is_visible() != visible { + row.set_visible(visible); + } + } + }, + )); + } + + let content_box = gtk::Box::new(gtk::Orientation::Vertical, 0); + content_box.append(&status_revealer); + content_box.append(&migration_revealer); + content_box.append(&stack); + + let toolbar_view = adw::ToolbarView::new(); + toolbar_view.add_top_bar(&header); + toolbar_view.set_content(Some(&content_box)); + let content_page = adw::NavigationPage::builder() + .title("Settings") + .child(&toolbar_view) + .build(); + + let split = adw::NavigationSplitView::builder() + .sidebar(&sidebar_page) + .content(&content_page) + .build(); + root.set_content(Some(&split)); + + // Ctrl+F focuses search from anywhere in the window. + { + let sender = sender.clone(); + let controller = gtk::EventControllerKey::new(); + controller.connect_key_pressed(move |_, key, _, modifiers| { + if modifiers.contains(gtk::gdk::ModifierType::CONTROL_MASK) + && matches!(key, gtk::gdk::Key::f | gtk::gdk::Key::F) + { + sender.input(Message::SearchFocusRequested); + return gtk::glib::Propagation::Stop; + } + // Tab is the user moving focus deliberately; a still-pending + // startup search focus must not steal it back later. + if matches!(key, gtk::gdk::Key::Tab | gtk::gdk::Key::ISO_Left_Tab) { + sender.input(Message::StartupInteractionObserved); + } + gtk::glib::Propagation::Proceed + }); + root.add_controller(controller); + } + + // Any click or tap is the same signal: the user is interacting, so + // the deferred startup search focus (which fires when the initial + // config load lands) must stand down instead of yanking focus. + { + let sender = sender.clone(); + let click = gtk::GestureClick::new(); + click.set_button(0); + click.set_propagation_phase(gtk::PropagationPhase::Capture); + click.connect_pressed(move |_, _, _, _| { + sender.input(Message::StartupInteractionObserved); + }); + root.add_controller(click); + } + + let widgets = AppWidgets { + window_title, + status_label, + status_revealer, + migration_revealer, + migration_label, + migration_seen: String::new(), + save_button, + defaults_button, + reload_button, + sidebar_rows, + sidebar, + stack, + search_entry, + seen_focus_serial: 0, + bindings, + }; + + ComponentParts { model, widgets } + } + + fn update(&mut self, message: Message, sender: ComponentSender, _root: &Self::Root) { + for effect in self.update_message(message) { + spawn_effect(effect, &sender); + } + } + + fn update_cmd( + &mut self, + message: Self::CommandOutput, + sender: ComponentSender, + _root: &Self::Root, + ) { + for effect in self.update_command(message) { + spawn_effect(effect, &sender); + } + } + + fn update_view(&self, widgets: &mut Self::Widgets, _sender: ComponentSender) { + // Header chrome. + let subtitle = if self.is_dirty { "Unsaved changes" } else { "" }; + if widgets.window_title.subtitle() != subtitle { + widgets.window_title.set_subtitle(subtitle); + } + // A color field the parser rejects is an edit that never reached the + // draft, so Save is not offered while one is on screen: pressing it + // would write the last value that parsed and lose the text being typed. + let save_enabled = self.is_dirty + && !self.is_saving + && !self.is_loading + && self.invalid_color_hex_count() == 0; + if widgets.save_button.is_sensitive() != save_enabled { + widgets.save_button.set_sensitive(save_enabled); + } + let busy = self.is_loading || self.is_saving; + if widgets.reload_button.is_sensitive() == busy { + widgets.reload_button.set_sensitive(!busy); + } + let defaults_label = if self.defaults_reset_pending { + "Confirm reset?" + } else { + "Defaults" + }; + if widgets.defaults_button.label().as_deref() != Some(defaults_label) { + widgets.defaults_button.set_label(defaults_label); + } + + // Status strip. + let (status_text, status_class) = match &self.status { + StatusMessage::Idle => ("", None), + StatusMessage::Info(text) => (text.as_str(), None), + StatusMessage::Success(text) => (text.as_str(), Some("success")), + StatusMessage::Warning(text) => (text.as_str(), Some("warning")), + StatusMessage::Error(text) => (text.as_str(), Some("error")), + }; + if widgets.status_label.text() != status_text { + widgets.status_label.set_text(status_text); + for class in ["success", "warning", "error"] { + widgets.status_label.remove_css_class(class); + } + if let Some(class) = status_class { + widgets.status_label.add_css_class(class); + } + } + widgets + .status_revealer + .set_reveal_child(!status_text.is_empty()); + + // Migration offer. + let migration_text = self + .pending_migration() + .map(super::update::migration_offer_text) + .unwrap_or_default(); + if widgets.migration_seen != migration_text { + widgets.migration_label.set_text(&migration_text); + widgets.migration_seen = migration_text.clone(); + } + widgets + .migration_revealer + .set_reveal_child(!migration_text.is_empty()); + + // Navigation: model decides, widgets follow. + let stack_name = pages::stack_name(self.active_tab); + if widgets.stack.visible_child_name().as_deref() != Some(stack_name) { + widgets.stack.set_visible_child_name(stack_name); + } + let selected = widgets + .sidebar_rows + .iter() + .find(|(tab, _)| *tab == self.active_tab) + .map(|(_, row)| row.clone()); + if let Some(row) = selected + && widgets.sidebar.selected_row().as_ref() != Some(&row) + { + widgets.sidebar.select_row(Some(&row)); + } + + // Search text + one-shot focus grabs. + let query = self.search_query.raw(); + if widgets.search_entry.text() != query { + widgets.search_entry.set_text(query); + } + if widgets.seen_focus_serial != self.search_focus_serial { + widgets.seen_focus_serial = self.search_focus_serial; + widgets.search_entry.grab_focus(); + } + + // Page rows. + let summary = self.search_summary(); + // `&mut`: a binding may own the state its section needs between + // refreshes, which is what the dynamic lists keep their built rows in. + for binding in &mut widgets.bindings { + binding(self, &summary); + } + } +} + +/// Runs one effect as a Relm4 command; its result re-enters the component +/// as an ordinary message through `update_cmd`. +fn spawn_effect(effect: Effect, sender: &ComponentSender) { + match effect { + Effect::LoadConfig => sender.oneshot_command(async { + CommandMessage::ConfigLoaded(io::load_config_from_disk().await) + }), + Effect::SaveConfig { document, config } => sender.oneshot_command(async move { + CommandMessage::ConfigSaved(io::save_config_to_disk(document, *config).await) + }), + Effect::LoadDaemonStatus { request_id } => sender.oneshot_command(async move { + CommandMessage::DaemonStatusLoaded( + request_id, + daemon_setup::load_daemon_runtime_status().await, + ) + }), + Effect::PerformDaemonAction { + action, + shortcut_input, + } => sender.oneshot_command(async move { + CommandMessage::DaemonActionCompleted( + daemon_setup::perform_daemon_action(action, shortcut_input).await, + ) + }), + Effect::LoadSessionCatalog => sender.oneshot_command(async { + CommandMessage::SessionCatalogLoaded(session_catalog::load_session_catalog().await) + }), + Effect::ForgetSessionEntry { id } => sender.oneshot_command(async move { + CommandMessage::SessionCatalogActionCompleted( + session_catalog::forget_session_catalog_entry(id).await, + ) + }), + Effect::RenameSessionEntry { id, display_name } => sender.oneshot_command(async move { + CommandMessage::SessionCatalogActionCompleted( + session_catalog::rename_session_catalog_entry(id, display_name).await, + ) + }), + Effect::DuplicateSessionEntry { id, target } => sender.oneshot_command(async move { + CommandMessage::SessionCatalogActionCompleted( + session_catalog::duplicate_session_catalog_entry(id, target).await, + ) + }), + Effect::MoveSessionEntry { id, target } => sender.oneshot_command(async move { + CommandMessage::SessionCatalogActionCompleted( + session_catalog::move_session_catalog_entry(id, target).await, + ) + }), + Effect::RevealSessionEntry { id } => sender.oneshot_command(async move { + CommandMessage::SessionCatalogActionCompleted( + session_catalog::reveal_session_catalog_entry(id).await, + ) + }), + Effect::ClearSessionToolState { id } => sender.oneshot_command(async move { + CommandMessage::SessionCatalogActionCompleted( + session_catalog::clear_session_catalog_tool_state_entry(id).await, + ) + }), + Effect::ClearSessionEntry { id } => sender.oneshot_command(async move { + CommandMessage::SessionCatalogActionCompleted( + session_catalog::clear_session_catalog_entry(id).await, + ) + }), + } +} diff --git a/configurator/src/app/effects.rs b/configurator/src/app/effects.rs new file mode 100644 index 00000000..f76c68e4 --- /dev/null +++ b/configurator/src/app/effects.rs @@ -0,0 +1,58 @@ +//! Side effects the update layer asks for. +//! +//! Handlers stay pure functions of state: they mutate the model and return +//! the effects to run, never touching the GTK main loop or an async runtime +//! themselves. The component maps each effect onto a Relm4 command exactly +//! once ([`super::component`]), so the update layer stays framework-free +//! and handler tests assert on returned effects instead of driving a UI. + +use std::path::PathBuf; + +use wayscriber::config::{Config, ConfigDocument}; + +use crate::models::DaemonAction; + +/// One asynchronous job a handler wants started. Fields carry everything +/// the job needs, so running it reads nothing back out of the model. +#[derive(Debug)] +pub(crate) enum Effect { + LoadConfig, + /// The document leaves the model with the write and comes back with its + /// result, so exactly one owner holds it at any moment. + SaveConfig { + document: Box, + config: Box, + }, + LoadDaemonStatus { + request_id: u64, + }, + PerformDaemonAction { + action: DaemonAction, + shortcut_input: String, + }, + LoadSessionCatalog, + ForgetSessionEntry { + id: String, + }, + RenameSessionEntry { + id: String, + display_name: String, + }, + DuplicateSessionEntry { + id: String, + target: PathBuf, + }, + MoveSessionEntry { + id: String, + target: PathBuf, + }, + RevealSessionEntry { + id: String, + }, + ClearSessionToolState { + id: String, + }, + ClearSessionEntry { + id: String, + }, +} diff --git a/configurator/src/app/entry.rs b/configurator/src/app/entry.rs deleted file mode 100644 index 2e369073..00000000 --- a/configurator/src/app/entry.rs +++ /dev/null @@ -1,36 +0,0 @@ -use iced::{Settings, Size, application, window}; - -use crate::models::StartupRequest; - -use super::state::ConfiguratorApp; - -pub(crate) fn run(startup: StartupRequest) -> iced::Result { - let settings = Settings { - id: Some("wayscriber-configurator".to_string()), - ..Settings::default() - }; - let mut window = window::Settings { - size: Size::new(960.0, 640.0), - resizable: true, - decorations: true, - ..window::Settings::default() - }; - #[cfg(target_os = "linux")] - { - window.platform_specific.application_id = "wayscriber-configurator".to_string(); - } - application( - // The launch request is state the app starts with, so it travels into - // the model through the boot closure rather than through a static that - // the model would have to reach out for. - move || ConfiguratorApp::new_app_with_startup(startup.clone()), - ConfiguratorApp::update_message, - ConfiguratorApp::view, - ) - .title("Wayscriber Configurator (Iced)") - .subscription(ConfiguratorApp::subscription) - .theme(iced::Theme::Dark) - .settings(settings) - .window(window) - .run() -} diff --git a/configurator/src/app/io.rs b/configurator/src/app/io.rs index cd4c2f42..e98f7c63 100644 --- a/configurator/src/app/io.rs +++ b/configurator/src/app/io.rs @@ -1,29 +1,44 @@ -use std::{path::PathBuf, sync::Arc}; - use wayscriber::config::{Config, ConfigDocument}; +use crate::messages::ConfigSaveResult; + use super::blocking_jobs::{BlockingJobKind, run_blocking}; -pub(super) async fn load_config_from_disk() -> Result<(Arc, Option), String> +pub(super) async fn load_config_from_disk() -> Result<(Box, Option), String> { run_blocking(BlockingJobKind::ConfigLoad, || { ConfigDocument::load_for_editing() - .map(|(document, warning)| (Arc::new(document), warning)) + .map(|(document, warning)| (Box::new(document), warning)) .map_err(|err| format!("{err:#}")) }) .await } +/// Writes the configuration and hands the document back either way. +/// +/// The model gave its only copy away for the duration of the write, so a +/// refused or failed write has to return one: the outcome's document on +/// success, the borrowed one on failure. The single case with nothing to +/// return is a blocking job that did not come back at all, and the document it +/// held went with it. pub(super) async fn save_config_to_disk( - document: Arc, + document: Box, config: Config, -) -> Result<(Option, Arc), String> { - run_blocking(BlockingJobKind::ConfigSave, move || { - let outcome = document - .save_with_backup(config) - .map_err(|err| format!("{err:#}"))?; - let (document, backup) = outcome.into_parts(); - Ok((backup, Arc::new(document))) +) -> ConfigSaveResult { + let outcome = run_blocking(BlockingJobKind::ConfigSave, move || { + match document.save_with_backup(config) { + Ok(outcome) => { + let (saved, backup) = outcome.into_parts(); + Ok((Box::new(saved), Ok(backup))) + } + Err(err) => Ok((document, Err(format!("{err:#}")))), + } }) - .await + .await; + + match outcome { + Ok((document, Ok(backup))) => Ok((backup, document)), + Ok((document, Err(err))) => Err((Some(document), err)), + Err(err) => Err((None, err)), + } } diff --git a/configurator/src/app/mod.rs b/configurator/src/app/mod.rs index 8e673bcd..26d84ed4 100644 --- a/configurator/src/app/mod.rs +++ b/configurator/src/app/mod.rs @@ -1,14 +1,13 @@ mod blocking_jobs; +mod component; mod daemon_setup; -mod entry; +mod effects; mod io; -pub(crate) mod scroll; +mod pages; mod search; mod session_catalog; mod startup; mod state; -mod subscription; mod update; -mod view; -pub(crate) use entry::run; +pub(crate) use component::run; diff --git a/configurator/src/app/pages/arrow.rs b/configurator/src/app/pages/arrow.rs new file mode 100644 index 00000000..90de2c74 --- /dev/null +++ b/configurator/src/app/pages/arrow.rs @@ -0,0 +1,80 @@ +//! Arrow page: arrowhead geometry for the arrow tool. +//! +//! The two numeric fields keep the core config's documented ranges. The +//! Iced view showed those ranges as always-on hint text under each input; +//! an `AdwEntryRow` has no hint slot, so the range appears in the row's +//! error text instead, on the same `.error` styling every ported page uses. + +use relm4::prelude::*; + +use crate::messages::Message; +use crate::models::util::format_float; +use crate::models::{TabId, TextField, ToggleField}; + +use super::super::search::SearchArea; +use super::super::state::ConfiguratorApp; +use super::{BuiltPage, PageBuilder}; + +/// `ArrowConfig::length`, in pixels. +const LENGTH_RANGE: (f64, f64) = (5.0, 50.0); +/// `ArrowConfig::angle_degrees`, in degrees. +const ANGLE_RANGE: (f64, f64) = (15.0, 60.0); + +pub(super) fn build(sender: &ComponentSender) -> BuiltPage { + let mut page = PageBuilder::new(sender, TabId::Arrow); + + page.group_in_area("Arrow Settings", SearchArea::Arrow) + .entry_row_validated( + "Arrow length (px)", + |app| app.draft.arrow_length.clone(), + |value| Message::TextChanged(TextField::ArrowLength, value), + |app| validate_f64_range(&app.draft.arrow_length, LENGTH_RANGE.0, LENGTH_RANGE.1), + ) + .entry_row_validated( + "Arrow angle (deg)", + |app| app.draft.arrow_angle.clone(), + |value| Message::TextChanged(TextField::ArrowAngle, value), + |app| validate_f64_range(&app.draft.arrow_angle, ANGLE_RANGE.0, ANGLE_RANGE.1), + ) + .switch_row( + "Place arrowhead at end of line", + "Off draws the head at the start of the line instead.", + |app| app.draft.arrow_head_at_end, + |value| Message::ToggleChanged(ToggleField::ArrowHeadAtEnd, value), + ); + + page.finish() +} + +/// Error text for a numeric field constrained to `min..=max`, `None` while +/// the input is acceptable. +fn validate_f64_range(value: &str, min: f64, max: f64) -> Option { + match value.trim().parse::() { + Ok(parsed) if (min..=max).contains(&parsed) => None, + _ => Some(format!( + "Enter a number between {} and {}.", + format_float(min), + format_float(max) + )), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn validate_f64_range_accepts_the_bounds() { + assert_eq!(validate_f64_range("5", 5.0, 50.0), None); + assert_eq!(validate_f64_range(" 50 ", 5.0, 50.0), None); + assert_eq!(validate_f64_range("26.5", 15.0, 60.0), None); + } + + #[test] + fn validate_f64_range_reports_the_range_for_bad_input() { + let expected = Some("Enter a number between 5 and 50.".to_string()); + assert_eq!(validate_f64_range("4.9", 5.0, 50.0), expected); + assert_eq!(validate_f64_range("", 5.0, 50.0), expected); + assert_eq!(validate_f64_range("wide", 5.0, 50.0), expected); + } +} diff --git a/configurator/src/app/pages/boards.rs b/configurator/src/app/pages/boards.rs new file mode 100644 index 00000000..b15648be --- /dev/null +++ b/configurator/src/app/pages/boards.rs @@ -0,0 +1,896 @@ +//! Boards page: the templates a new session is seeded from. +//! +//! Boards are a user-sized list, so the per-board sections cannot be a fixed +//! set of [`PageBuilder`] rows. One container holds one `GtkListBox` per +//! board, and every section is built together with the closure that refreshes +//! it: that closure owns the section's typed widget handles and the signal +//! handler ids guarding each write, so a refresh never rediscovers a control +//! by position and never reports a programmatic write as a user edit. +//! +//! The binding owns both halves of that arrangement: the [`SectionLayout`] +//! list it last built, and one typed refresh closure per section. When the +//! layouts differ the two are replaced together, so a section and the values +//! handed to it can never drift apart — there is no count to check and no +//! mismatch to report. A rebuilt row gets its layout values before its +//! handlers connect, which is what keeps the first refresh from echoing the +//! model straight back at itself. +//! +//! Ids, names, and color text stay out of the layout on purpose: they change +//! on every keystroke, and rebuilding would tear the row the caret sits in out +//! from under it. Those values ride [`BoardValues`] into the sections' own +//! refresh closures, which write them with their handlers blocked — the hex +//! path is lossy in that direction, so an echo would quantize a board's float +//! components to whatever the 8-bit hex said. + +use relm4::prelude::*; +use relm4::{adw, gtk}; + +use adw::prelude::*; +use gtk::glib::SignalHandlerId; + +use crate::messages::Message; +use crate::models::color::parse_triplet_values; +use crate::models::{ + BoardBackgroundOption, BoardItemTextField, BoardItemToggleField, ColorPickerId, + ColorTripletInput, TabId, TextField, ToggleField, +}; + +use super::super::search::{AppSearchSummary, SearchArea, TabSearchSummary}; +use super::super::state::ConfiguratorApp; +use super::color_rows::{dialog_hex, mark_hex_error, set_swatch_blocked}; +use super::{BuiltPage, PageBuilder, set_text_blocked}; + +/// `GTK_INVALID_LIST_POSITION`: what a `GtkSingleSelection` reads as "nothing +/// is selected", which is how the Iced pick list rendered an unknown default. +const NO_SELECTION: u32 = u32::MAX; + +const INTRO: &str = "Templates used to seed a new session. Boards you add or rename in the overlay belong to that session, not to this list."; + +// Kept visible rather than hidden: the key is still in existing files, and a +// control that quietly disappears leaves the user to work out on their own why +// a setting they wrote stopped doing anything. +const PERSIST_NOTE: &str = "No effect: board renames, recolors, additions, and deletions belong to the running session, and nothing but Save writes config.toml. This setting is still parsed so older files load cleanly, and will be removed."; + +const LEGACY_NOTE: &str = + "Legacy [board] settings detected. Editing board settings will write [boards]."; + +pub(super) fn build(sender: &ComponentSender) -> BuiltPage { + let mut page = PageBuilder::new(sender, TabId::Boards); + + // The list below is the seed, not the running session, so the heading has + // to say which of the two this screen edits. + page.group_in_area("Boards", SearchArea::BoardsGeneral); + page.custom(¬e_label(INTRO)); + + page.group_in_area("", SearchArea::BoardsGeneral) + .entry_row_validated( + "Max boards", + |app| app.draft.boards.max_count.clone(), + |value| Message::TextChanged(TextField::BoardsMaxCount, value), + |app| validate_min(&app.draft.boards.max_count, 1), + ) + .switch_row( + "Auto-create missing boards", + "", + |app| app.draft.boards.auto_create, + |value| Message::ToggleChanged(ToggleField::BoardsAutoCreate, value), + ) + .switch_row( + "Show board badge", + "", + |app| app.draft.boards.show_board_badge, + |value| Message::ToggleChanged(ToggleField::BoardsShowBadge, value), + ) + .switch_row( + "Persist runtime customizations (deprecated)", + PERSIST_NOTE, + |app| app.draft.boards.persist_customizations, + |value| Message::ToggleChanged(ToggleField::BoardsPersistCustomizations, value), + ); + + add_default_board_row(&mut page); + add_add_button(&mut page); + add_legacy_note(&mut page); + add_board_list(&mut page); + + page.finish() +} + +/// The default-board picker: a combo whose model is the effective board ids, +/// which change as the user edits an id. +fn add_default_board_row(page: &mut PageBuilder) { + let row = adw::ComboRow::builder().title("Default board").build(); + row.set_model(Some(>k::StringList::new(&[]))); + let handler = { + let sender = page.sender(); + row.connect_selected_notify(move |row| { + let Some(item) = row + .selected_item() + .and_then(|item| item.downcast::().ok()) + else { + return; + }; + sender.input(Message::BoardsDefaultChanged(item.string().to_string())); + }) + }; + page.custom(&row); + // The combo's own entries, kept by the binding that writes them: a model + // is only replaced when the choices themselves changed. + let mut shown: Vec = Vec::new(); + page.bind(move |app, _summary| { + let ids = app.draft.boards.effective_ids(); + let selected = ids + .iter() + .position(|id| *id == app.draft.boards.default_board); + sync_combo(&row, &handler, &mut shown, &ids, selected); + + let has_boards = !ids.is_empty(); + if row.is_sensitive() != has_boards { + row.set_sensitive(has_boards); + } + let subtitle = if has_boards { + "" + } else { + "Add a board to choose a default" + }; + if row.subtitle().as_deref() != Some(subtitle) { + row.set_subtitle(subtitle); + } + }); +} + +fn add_add_button(page: &mut PageBuilder) { + let button = gtk::Button::builder() + .label("Add board") + .halign(gtk::Align::Start) + .margin_top(12) + .build(); + { + let sender = page.sender(); + button.connect_clicked(move |_| sender.input(Message::BoardsAddItem)); + } + page.custom(&button); +} + +fn add_legacy_note(page: &mut PageBuilder) { + let label = note_label(LEGACY_NOTE); + label.set_margin_top(6); + // Hidden until a load reports a legacy file, so it cannot flash on the + // first frame. + label.set_visible(false); + page.custom(&label); + page.bind(move |app, _summary| { + let visible = app + .base_document + .as_ref() + .is_some_and(|document| document.config().boards.is_none()); + if label.is_visible() != visible { + label.set_visible(visible); + } + }); +} + +/// The per-board sections, in their own untitled group so search can hide +/// them one at a time instead of as a block. +fn add_board_list(page: &mut PageBuilder) { + page.group(""); + let container = gtk::Box::builder() + .orientation(gtk::Orientation::Vertical) + .spacing(18) + .build(); + page.custom(&container); + + let sender = page.sender(); + // Each built section owns the layout that produced it and its typed refresh. + let mut sections: Vec = Vec::new(); + page.bind(move |app, summary| { + let layouts = section_layouts(app, summary); + if !sections + .iter() + .map(|section| section.layout) + .eq(layouts.iter().copied()) + { + sections = rebuild_sections(&container, &layouts, &sender); + } + for ((index, item), section) in app + .draft + .boards + .items + .iter() + .enumerate() + .zip(sections.iter()) + { + let values = BoardValues { + id: &item.id, + name: &item.name, + background: ColorValues { + hex: picker_hex(app, ColorPickerId::BoardBackground(index)), + color: &item.background_color, + }, + pen: ColorValues { + hex: picker_hex(app, ColorPickerId::BoardPen(index)), + color: &item.default_pen_color.color, + }, + }; + (section.refresh)(&values); + } + }); +} + +// ---- What a rebuild depends on ----------------------------------------- + +/// Everything a board section shows before any text is written into it. +/// +/// This is the whole rebuild trigger: the binding compares the list of these +/// it built against the list the model asks for now, so a control added here +/// rebuilds on its own value without anyone having to remember to extend a +/// format string. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct SectionLayout { + visible: bool, + expanded: bool, + background_kind: BoardBackgroundOption, + pen_enabled: bool, + auto_adjust: bool, + persist: bool, + pinned: bool, +} + +fn section_layouts(app: &ConfiguratorApp, summary: &AppSearchSummary) -> Vec { + let tab = summary.tab(TabId::Boards); + let show_all = tab.is_none_or(TabSearchSummary::show_all); + let matched: &[usize] = tab.map_or(&[], TabSearchSummary::board_indices); + + app.draft + .boards + .items + .iter() + .enumerate() + .map(|(index, item)| SectionLayout { + visible: show_all || matched.contains(&index), + // A search that singled this board out expands it: a matched board + // that stays folded shut is a match the user cannot read. + expanded: !show_all || !is_collapsed(app, index), + background_kind: item.background_kind, + pen_enabled: item.default_pen_color.enabled, + auto_adjust: item.auto_adjust_pen, + persist: item.persist, + pinned: item.pinned, + }) + .collect() +} + +/// One board row's values: everything the layout deliberately left out. +struct BoardValues<'a> { + id: &'a str, + name: &'a str, + background: ColorValues<'a>, + pen: ColorValues<'a>, +} + +/// One color field's values: the picker's editing hex, and the draft's own +/// triplet, so the swatch parses the components by the same rules the draft +/// does. +struct ColorValues<'a> { + hex: &'a str, + color: &'a ColorTripletInput, +} + +// ---- Sections ---------------------------------------------------------- + +/// One section's refresh: built beside its row, so it owns that row's typed +/// widget handles and the signal handler ids guarding each write. No +/// positional lookup, and no widget the refresh can silently miss. +type BoardRowRefresh = Box)>; + +struct BoundBoardSection { + layout: SectionLayout, + refresh: BoardRowRefresh, +} + +/// One board's section: the list box, and the closure that writes the values +/// the layout deliberately left out. +struct BoardSection { + section: gtk::ListBox, + refresh: BoardRowRefresh, +} + +fn rebuild_sections( + container: >k::Box, + layouts: &[SectionLayout], + sender: &ComponentSender, +) -> Vec { + // Draining the container, not walking it for a control: the sections that + // replace these carry their own refresh closures. + while let Some(child) = container.first_child() { + container.remove(&child); + } + + let mut sections = Vec::with_capacity(layouts.len()); + for (index, layout) in layouts.iter().enumerate() { + let built = build_section(index, *layout, sender); + container.append(&built.section); + sections.push(BoundBoardSection { + layout: *layout, + refresh: built.refresh, + }); + } + sections +} + +fn build_section( + index: usize, + layout: SectionLayout, + sender: &ComponentSender, +) -> BoardSection { + let section = gtk::ListBox::builder() + .selection_mode(gtk::SelectionMode::None) + .css_classes(["boxed-list"]) + .visible(layout.visible) + .build(); + + let header = build_header_row(index, layout.expanded, sender); + section.append(&header.row); + + let id = build_text_row("Board id", index, BoardItemTextField::Id, sender); + id.row.set_visible(layout.expanded); + section.append(&id.row); + + let name = build_text_row("Display name", index, BoardItemTextField::Name, sender); + name.row.set_visible(layout.expanded); + section.append(&name.row); + + let kind_row = build_kind_row(index, layout.background_kind, sender); + kind_row.set_visible(layout.expanded); + section.append(&kind_row); + + let background = build_color_row( + "Background color (0-1)", + ColorPickerId::BoardBackground(index), + index, + Message::BoardsBackgroundColorChanged, + sender, + ); + // The Iced view swapped this for a note explaining why the color is + // inert; a hidden row says the same thing without the clutter. + background + .row + .set_visible(layout.expanded && layout.background_kind == BoardBackgroundOption::Color); + section.append(&background.row); + + let pen_enabled = adw::SwitchRow::builder() + .title("Override default pen color") + .active(layout.pen_enabled) + .visible(layout.expanded) + .build(); + { + let sender = sender.clone(); + pen_enabled.connect_active_notify(move |row| { + sender.input(Message::BoardsDefaultPenEnabledChanged( + index, + row.is_active(), + )); + }); + } + section.append(&pen_enabled); + + let pen = build_color_row( + "Pen color (0-1)", + ColorPickerId::BoardPen(index), + index, + Message::BoardsDefaultPenColorChanged, + sender, + ); + pen.row.set_visible(layout.expanded && layout.pen_enabled); + section.append(&pen.row); + + for (title, field, active) in [ + ( + "Auto-adjust pen", + BoardItemToggleField::AutoAdjustPen, + layout.auto_adjust, + ), + ("Persist", BoardItemToggleField::Persist, layout.persist), + ( + "Configured default pinned", + BoardItemToggleField::Pinned, + layout.pinned, + ), + ] { + section.append(&build_toggle_row( + title, + index, + field, + active, + layout.expanded, + sender, + )); + } + + let refresh: BoardRowRefresh = Box::new(move |values| { + header.set_labels(index, values.id, values.name); + set_text_blocked(&id.row, &id.handler, values.id); + set_text_blocked(&name.row, &name.handler, values.name); + background.refresh(&values.background); + pen.refresh(&values.pen); + }); + + BoardSection { section, refresh } +} + +// ---- Header row -------------------------------------------------------- + +/// The header's two labels, which restate the id and name rows below them. +struct HeaderRow { + row: gtk::ListBoxRow, + title: gtk::Label, + id: gtk::Label, +} + +impl HeaderRow { + fn set_labels(&self, index: usize, id: &str, name: &str) { + let title = if name.trim().is_empty() { + format!("Board {}", index + 1) + } else { + name.trim().to_string() + }; + set_label(&self.title, &title); + + let id_label = if id.trim().is_empty() { + "id: ".to_string() + } else { + format!("id: {}", id.trim()) + }; + set_label(&self.id, &id_label); + } +} + +fn build_header_row( + index: usize, + expanded: bool, + sender: &ComponentSender, +) -> HeaderRow { + let labels = gtk::Box::builder() + .orientation(gtk::Orientation::Vertical) + .spacing(2) + .hexpand(true) + .build(); + let title = gtk::Label::builder() + .xalign(0.0) + .css_classes(["heading"]) + .build(); + let id = gtk::Label::builder() + .xalign(0.0) + .css_classes(["dim-label", "caption"]) + .build(); + labels.append(&title); + labels.append(&id); + + let content = row_content_box(gtk::Orientation::Horizontal); + content.append(&labels); + for (label, message) in [ + ( + if expanded { "Collapse" } else { "Expand" }, + Message::BoardsCollapseToggled(index), + ), + ("Up", Message::BoardsMoveItemUp(index)), + ("Down", Message::BoardsMoveItemDown(index)), + ("Duplicate", Message::BoardsDuplicateItem(index)), + ("Remove", Message::BoardsRemoveItem(index)), + ] { + let button = gtk::Button::builder() + .label(label) + .valign(gtk::Align::Center) + .build(); + connect_button(&button, message, sender); + content.append(&button); + } + + HeaderRow { + row: plain_row(&content), + title, + id, + } +} + +// ---- Color row --------------------------------------------------------- + +/// The Iced view's triplet picker: a hex field, a popup picker, and the three +/// raw components. The native color dialog stands in for the popup; both feed +/// the same `ColorPickerHexChanged` path the popup's hex field used. +struct ColorRow { + row: gtk::ListBoxRow, + hex: gtk::Entry, + hex_handler: SignalHandlerId, + swatch: gtk::ColorDialogButton, + swatch_handler: SignalHandlerId, + components: [ComponentEntry; 3], +} + +struct ComponentEntry { + entry: gtk::Entry, + handler: SignalHandlerId, +} + +const COMPONENT_PLACEHOLDERS: [&str; 3] = ["R", "G", "B"]; + +impl ColorRow { + fn refresh(&self, values: &ColorValues<'_>) { + set_text_blocked(&self.hex, &self.hex_handler, values.hex); + // The same predicate the save gate counts with, so a field styled + // clean can never be one the save refuses. + mark_hex_error(&self.hex, values.hex); + + for (component, value) in self.components.iter().zip(values.color.components.iter()) { + set_text_blocked(&component.entry, &component.handler, value); + } + + let rgb = parse_triplet_values(&values.color.components); + let rgba = gtk::gdk::RGBA::new(rgb[0] as f32, rgb[1] as f32, rgb[2] as f32, 1.0); + set_swatch_blocked(&self.swatch, &self.swatch_handler, &rgba); + } +} + +fn build_color_row( + title: &str, + id: ColorPickerId, + index: usize, + to_component: fn(usize, usize, String) -> Message, + sender: &ComponentSender, +) -> ColorRow { + let controls = gtk::Box::builder() + .orientation(gtk::Orientation::Horizontal) + .spacing(6) + .build(); + + let hex = gtk::Entry::builder() + .placeholder_text("#RRGGBB") + .width_chars(9) + .max_width_chars(9) + .build(); + let hex_handler = { + let sender = sender.clone(); + hex.connect_changed(move |entry| { + sender.input(Message::ColorPickerHexChanged(id, entry.text().to_string())); + }) + }; + controls.append(&hex); + + let swatch = + gtk::ColorDialogButton::new(Some(gtk::ColorDialog::builder().with_alpha(false).build())); + swatch.set_valign(gtk::Align::Center); + let swatch_handler = { + let sender = sender.clone(); + swatch.connect_rgba_notify(move |button| { + sender.input(Message::ColorPickerHexChanged( + id, + dialog_hex(&button.rgba()), + )); + }) + }; + controls.append(&swatch); + + let components = std::array::from_fn(|component| { + let entry = gtk::Entry::builder() + .placeholder_text(COMPONENT_PLACEHOLDERS.get(component).copied().unwrap_or("")) + .width_chars(6) + .max_width_chars(6) + .build(); + let handler = { + let sender = sender.clone(); + entry.connect_changed(move |entry| { + sender.input(to_component(index, component, entry.text().to_string())); + }) + }; + controls.append(&entry); + ComponentEntry { entry, handler } + }); + + let content = row_content_box(gtk::Orientation::Vertical); + content.append( + >k::Label::builder() + .label(title) + .xalign(0.0) + .css_classes(["dim-label", "caption"]) + .build(), + ); + content.append(&controls); + + ColorRow { + row: plain_row(&content), + hex, + hex_handler, + swatch, + swatch_handler, + components, + } +} + +// ---- Small shared plumbing -------------------------------------------- + +/// An entry row whose text the model owns, kept with the handler a refresh +/// has to block before writing it. +struct TextRow { + row: adw::EntryRow, + handler: SignalHandlerId, +} + +fn build_text_row( + title: &str, + index: usize, + field: BoardItemTextField, + sender: &ComponentSender, +) -> TextRow { + let row = adw::EntryRow::builder().title(title).build(); + let handler = { + let sender = sender.clone(); + row.connect_changed(move |row| { + sender.input(Message::BoardsItemTextChanged( + index, + field, + row.text().to_string(), + )); + }) + }; + TextRow { row, handler } +} + +fn build_kind_row( + index: usize, + selected: BoardBackgroundOption, + sender: &ComponentSender, +) -> adw::ComboRow { + let options = BoardBackgroundOption::list(); + let labels: Vec = options + .iter() + .map(|option| option.label().to_string()) + .collect(); + let label_refs: Vec<&str> = labels.iter().map(String::as_str).collect(); + let row = adw::ComboRow::builder() + .title("Background") + .model(>k::StringList::new(&label_refs)) + .build(); + + // The selection is part of the rebuild fingerprint, so it is set before + // the handler exists and never written again behind the user's back. + if let Some(position) = options.iter().position(|option| *option == selected) { + row.set_selected(position as u32); + } + { + let sender = sender.clone(); + row.connect_selected_notify(move |row| { + if let Some(option) = options.get(row.selected() as usize) { + sender.input(Message::BoardsBackgroundKindChanged(index, *option)); + } + }); + } + row +} + +fn build_toggle_row( + title: &str, + index: usize, + field: BoardItemToggleField, + active: bool, + expanded: bool, + sender: &ComponentSender, +) -> adw::SwitchRow { + let row = adw::SwitchRow::builder() + .title(title) + .active(active) + .visible(expanded) + .build(); + let sender = sender.clone(); + row.connect_active_notify(move |row| { + sender.input(Message::BoardsItemToggleChanged( + index, + field, + row.is_active(), + )); + }); + row +} + +fn connect_button( + button: >k::Button, + message: Message, + sender: &ComponentSender, +) { + let sender = sender.clone(); + button.connect_clicked(move |_| sender.input(message.clone())); +} + +fn set_label(label: >k::Label, value: &str) { + if label.text() != value { + label.set_text(value); + } +} + +/// Rewrites a combo's model only when the choices themselves changed, and +/// writes both model and selection with the change handler blocked: replacing +/// a model resets the selection to the first row, which would otherwise be +/// reported as if the user had picked it. +/// +/// `shown` is what the combo currently offers, owned by the binding that +/// calls this — the entries themselves, not a rendering of them. +fn sync_combo( + row: &adw::ComboRow, + handler: &SignalHandlerId, + shown: &mut Vec, + entries: &[String], + selected: Option, +) { + let rebuild = shown.as_slice() != entries; + let target = selected.map_or(NO_SELECTION, |index| index as u32); + if !rebuild && row.selected() == target { + return; + } + + row.block_signal(handler); + if rebuild { + let refs: Vec<&str> = entries.iter().map(String::as_str).collect(); + row.set_model(Some(>k::StringList::new(&refs))); + shown.clear(); + shown.extend_from_slice(entries); + } + if row.selected() != target { + row.set_selected(target); + } + row.unblock_signal(handler); +} + +fn row_content_box(orientation: gtk::Orientation) -> gtk::Box { + gtk::Box::builder() + .orientation(orientation) + .spacing(6) + .margin_top(8) + .margin_bottom(8) + .margin_start(12) + .margin_end(12) + .build() +} + +fn plain_row(content: &impl IsA) -> gtk::ListBoxRow { + gtk::ListBoxRow::builder() + .child(content) + .activatable(false) + .selectable(false) + .build() +} + +fn note_label(text: &str) -> gtk::Label { + gtk::Label::builder() + .label(text) + .wrap(true) + .xalign(0.0) + .css_classes(["dim-label", "caption"]) + .build() +} + +fn picker_hex(app: &ConfiguratorApp, id: ColorPickerId) -> &str { + app.color_picker_hex.get(&id).map_or("", String::as_str) +} + +fn is_collapsed(app: &ConfiguratorApp, index: usize) -> bool { + app.boards_collapsed.get(index).copied().unwrap_or(false) +} + +/// Error text for a count field with a lower bound, worded as the Iced view +/// worded it. +fn validate_min(value: &str, min: usize) -> Option { + match value.trim().parse::() { + Ok(parsed) if parsed >= min => None, + Ok(_) => Some(format!("Minimum: {min}")), + Err(_) => Some("Expected a whole number".to_string()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::models::SearchQuery; + + /// Two boards, so a fingerprint covers more than one section. + fn app_with_two_boards() -> ConfiguratorApp { + let (mut app, _effects) = ConfiguratorApp::new_app(); + while app.draft.boards.items.len() < 2 { + let Some(item) = app.draft.boards.items.first().cloned() else { + break; + }; + app.draft.boards.items.push(item); + app.boards_collapsed.push(false); + } + app + } + + /// The law the binding rests on: one layout per board, so the sections it + /// builds and the values it hands them are indexed by the same thing. + #[test] + fn there_is_one_layout_per_board() { + let app = app_with_two_boards(); + let layouts = section_layouts(&app, &app.search_summary()); + + assert_eq!(layouts.len(), app.draft.boards.items.len()); + } + + /// The caret guarantee, stated as the layout law it rests on: no keystroke + /// in a board's text may rebuild the row it lands in. + #[test] + fn typing_into_a_board_field_leaves_the_layout_alone() { + let mut app = app_with_two_boards(); + let summary = app.search_summary(); + let before = section_layouts(&app, &summary); + + let Some(item) = app.draft.boards.items.first_mut() else { + return; + }; + item.name = "Half typ".to_string(); + item.id = "half-typ".to_string(); + item.background_color.set_component(0, "0.1".to_string()); + app.color_picker_hex + .insert(ColorPickerId::BoardBackground(0), "#1A00".to_string()); + + let summary = app.search_summary(); + assert_eq!(before, section_layouts(&app, &summary)); + // The values a refresh writes in place carry the edit instead. + assert_eq!(app.draft.boards.items[0].id, "half-typ"); + assert_eq!( + app.color_picker_hex + .get(&ColorPickerId::BoardBackground(0)) + .map(String::as_str), + Some("#1A00") + ); + } + + #[test] + fn toggling_a_board_switch_changes_the_layout() { + let mut app = app_with_two_boards(); + let summary = app.search_summary(); + let before = section_layouts(&app, &summary); + + let Some(item) = app.draft.boards.items.first_mut() else { + return; + }; + item.pinned = !item.pinned; + + let summary = app.search_summary(); + assert_ne!(before, section_layouts(&app, &summary)); + } + + #[test] + fn collapsing_a_board_changes_the_layout() { + let mut app = app_with_two_boards(); + let summary = app.search_summary(); + let before = section_layouts(&app, &summary); + + let Some(collapsed) = app.boards_collapsed.first_mut() else { + return; + }; + *collapsed = true; + + let summary = app.search_summary(); + assert_ne!(before, section_layouts(&app, &summary)); + } + + /// Search visibility belongs to the layout too: a rebuild is what applies + /// it now that nothing refreshes a section in place. + #[test] + fn a_search_that_hides_a_board_changes_the_layout() { + let mut app = app_with_two_boards(); + let Some(item) = app.draft.boards.items.first_mut() else { + return; + }; + item.name = "zqxwvu".to_string(); + let summary = app.search_summary(); + let before = section_layouts(&app, &summary); + + app.search_query = SearchQuery::new("zqxwvu"); + let summary = app.search_summary(); + let layouts = section_layouts(&app, &summary); + + assert_ne!(before, layouts); + let visible: Vec = layouts.iter().map(|layout| layout.visible).collect(); + assert_eq!(visible.first(), Some(&true)); + assert!(visible.iter().skip(1).all(|visible| !visible)); + } +} diff --git a/configurator/src/app/pages/capture.rs b/configurator/src/app/pages/capture.rs new file mode 100644 index 00000000..6bf07d59 --- /dev/null +++ b/configurator/src/app/pages/capture.rs @@ -0,0 +1,240 @@ +//! Capture page: screenshot destinations and the PDF export block. +//! +//! Both label colors route through the shared color row, so the hex text and +//! the color dialog feed the same `ColorPickerHexChanged` the Iced picker's +//! hex field sent; the four RGBA component boxes it drew are gone with it. + +use relm4::prelude::*; + +use crate::messages::Message; +use crate::models::color::parse_quad_values; +use crate::models::util::format_float; +use crate::models::{ + ColorPickerId, ColorQuadInput, PdfFitModeOption, PdfLabelContentModeOption, + PdfLabelPositionOption, PdfOrientationOption, PdfPageSizeOption, + PdfTransparentBackgroundOption, TabId, TextField, ToggleField, +}; + +use super::super::search::SearchArea; +use super::super::state::ConfiguratorApp; +use super::color_rows::{ResolvedColor, color_row}; +use super::{BuiltPage, PageBuilder}; + +pub(super) fn build(sender: &ComponentSender) -> BuiltPage { + let mut page = PageBuilder::new(sender, TabId::Capture); + + page.group_in_area("Capture", SearchArea::CaptureFiles) + .switch_row( + "Enable capture shortcuts", + "", + |app| app.draft.capture_enabled, + |value| Message::ToggleChanged(ToggleField::CaptureEnabled, value), + ) + .entry_row( + "Save directory", + |app| app.draft.capture_save_directory.clone(), + |value| Message::TextChanged(TextField::CaptureSaveDirectory, value), + ) + .entry_row( + "Filename template", + |app| app.draft.capture_filename_template.clone(), + |value| Message::TextChanged(TextField::CaptureFilename, value), + ) + .entry_row( + "Format (png, jpg, ...)", + |app| app.draft.capture_format.clone(), + |value| Message::TextChanged(TextField::CaptureFormat, value), + ) + .switch_row( + "Copy to clipboard", + "", + |app| app.draft.capture_copy_to_clipboard, + |value| Message::ToggleChanged(ToggleField::CaptureCopyToClipboard, value), + ) + .switch_row( + "Always exit overlay after capture", + "", + |app| app.draft.capture_exit_after, + |value| Message::ToggleChanged(ToggleField::CaptureExitAfter, value), + ); + + page.group_in_area("PDF export", SearchArea::CapturePdf) + .entry_row( + "PDF filename template (blank = capture template)", + |app| app.draft.export_pdf_filename_template.clone(), + |value| Message::TextChanged(TextField::ExportPdfFilenameTemplate, value), + ) + .entry_row( + "All boards PDF filename template", + |app| app.draft.export_pdf_all_boards_filename_template.clone(), + |value| Message::TextChanged(TextField::ExportPdfAllBoardsFilenameTemplate, value), + ) + .combo_row( + "Page size", + "", + PdfPageSizeOption::list(), + labels(PdfPageSizeOption::list(), PdfPageSizeOption::label), + |app| app.draft.export_pdf_page_size, + Message::ExportPdfPageSizeChanged, + ) + .combo_row( + "Orientation", + "", + PdfOrientationOption::list(), + labels(PdfOrientationOption::list(), PdfOrientationOption::label), + |app| app.draft.export_pdf_orientation, + Message::ExportPdfOrientationChanged, + ) + .combo_row( + "Fit", + "", + PdfFitModeOption::list(), + labels(PdfFitModeOption::list(), PdfFitModeOption::label), + |app| app.draft.export_pdf_fit, + Message::ExportPdfFitChanged, + ) + .combo_row( + "Transparent page background", + "", + PdfTransparentBackgroundOption::list(), + labels( + PdfTransparentBackgroundOption::list(), + PdfTransparentBackgroundOption::label, + ), + |app| app.draft.export_pdf_transparent_background, + Message::ExportPdfTransparentBackgroundChanged, + ) + .entry_row_validated( + "Custom width (PDF points)", + |app| app.draft.export_pdf_custom_width.clone(), + |value| Message::TextChanged(TextField::ExportPdfCustomWidth, value), + |app| validate_f64_range(&app.draft.export_pdf_custom_width, 1.0, 14400.0), + ) + .entry_row_validated( + "Custom height (PDF points)", + |app| app.draft.export_pdf_custom_height.clone(), + |value| Message::TextChanged(TextField::ExportPdfCustomHeight, value), + |app| validate_f64_range(&app.draft.export_pdf_custom_height, 1.0, 14400.0), + ) + .entry_row_validated( + "Content source padding", + |app| app.draft.export_pdf_content_source_padding.clone(), + |value| Message::TextChanged(TextField::ExportPdfContentSourcePadding, value), + |app| validate_f64_range(&app.draft.export_pdf_content_source_padding, 0.0, 4096.0), + ) + .switch_row( + "Show PDF page labels", + "", + |app| app.draft.export_pdf_labels_enabled, + |value| Message::ToggleChanged(ToggleField::ExportPdfLabelsEnabled, value), + ) + .combo_row( + "Label position", + "", + PdfLabelPositionOption::list(), + labels( + PdfLabelPositionOption::list(), + PdfLabelPositionOption::label, + ), + |app| app.draft.export_pdf_label_position, + Message::ExportPdfLabelPositionChanged, + ) + .combo_row( + "Label content", + "", + PdfLabelContentModeOption::list(), + labels( + PdfLabelContentModeOption::list(), + PdfLabelContentModeOption::label, + ), + |app| app.draft.export_pdf_label_content, + Message::ExportPdfLabelContentChanged, + ) + .entry_row( + "Label template", + |app| app.draft.export_pdf_label_template.clone(), + |value| Message::TextChanged(TextField::ExportPdfLabelTemplate, value), + ) + .entry_row( + "Label font family", + |app| app.draft.export_pdf_label_font_family.clone(), + |value| Message::TextChanged(TextField::ExportPdfLabelFontFamily, value), + ) + .entry_row_validated( + "Label font size", + |app| app.draft.export_pdf_label_font_size.clone(), + |value| Message::TextChanged(TextField::ExportPdfLabelFontSize, value), + |app| validate_f64_range(&app.draft.export_pdf_label_font_size, 1.0, 72.0), + ) + .entry_row_validated( + "Label margin", + |app| app.draft.export_pdf_label_margin.clone(), + |value| Message::TextChanged(TextField::ExportPdfLabelMargin, value), + |app| validate_f64_range(&app.draft.export_pdf_label_margin, 0.0, 240.0), + ) + .entry_row_validated( + "Label horizontal padding", + |app| app.draft.export_pdf_label_padding_x.clone(), + |value| Message::TextChanged(TextField::ExportPdfLabelPaddingX, value), + |app| validate_f64_range(&app.draft.export_pdf_label_padding_x, 0.0, 120.0), + ) + .entry_row_validated( + "Label vertical padding", + |app| app.draft.export_pdf_label_padding_y.clone(), + |value| Message::TextChanged(TextField::ExportPdfLabelPaddingY, value), + |app| validate_f64_range(&app.draft.export_pdf_label_padding_y, 0.0, 120.0), + ); + + color_row( + &mut page, + "Label text color", + ColorPickerId::ExportPdfLabelText, + |app| quad_color(&app.draft.export_pdf_label_text_color), + ); + + page.switch_row( + "Label solid background", + "", + |app| app.draft.export_pdf_label_background_enabled, + |value| Message::ToggleChanged(ToggleField::ExportPdfLabelBackgroundEnabled, value), + ); + + color_row( + &mut page, + "Label background color", + ColorPickerId::ExportPdfLabelBackground, + |app| quad_color(&app.draft.export_pdf_label_background_color), + ); + + page.finish() +} + +/// Error text for a fractional field constrained to `min..=max`, `None` +/// while the input is acceptable. +/// +/// The tablet page validates the same way; `pages/mod.rs` owns the +/// whole-number twin of this helper. +pub(super) fn validate_f64_range(value: &str, min: f64, max: f64) -> Option { + match value.trim().parse::() { + Ok(parsed) if parsed.is_finite() && (min..=max).contains(&parsed) => None, + _ => Some(format!( + "Enter a number between {} and {}.", + format_float(min), + format_float(max) + )), + } +} + +/// The dialog/swatch seed for a `0.0..=1.0` RGBA quad. +fn quad_color(input: &ColorQuadInput) -> ResolvedColor { + let [red, green, blue, alpha] = parse_quad_values(&input.components); + Some((red, green, blue, alpha)) +} + +/// Combo labels in the same order as the values they name. +fn labels(values: Vec, label: impl Fn(O) -> &'static str) -> Vec { + values + .into_iter() + .map(|value| label(value).to_string()) + .collect() +} diff --git a/configurator/src/app/pages/color_rows.rs b/configurator/src/app/pages/color_rows.rs new file mode 100644 index 00000000..31e64875 --- /dev/null +++ b/configurator/src/app/pages/color_rows.rs @@ -0,0 +1,161 @@ +//! Shared color-editing row. +//! +//! Every color the configurator edits routes through the model's hex path: +//! the entry row sends `ColorPickerHexChanged` exactly as the Iced picker's +//! hex field did, and the native color dialog feeds the same message with a +//! formatted hex string. The update layer already parses, applies, and +//! keeps `color_picker_hex` as the per-picker editing text, so this row has +//! no color state of its own. +//! +//! A refresh writes the widgets with their handlers blocked. Without that a +//! programmatic write reports itself as a user edit, and the hex path is +//! lossy in that direction: it re-derives every component from the 8-bit hex, +//! so simply showing a loaded config would quantize float colors and mark the +//! draft dirty. + +use relm4::{adw, gtk}; + +use adw::prelude::*; +use gtk::glib::SignalHandlerId; + +use crate::messages::Message; +use crate::models::ColorPickerId; +use crate::models::color::hex_field_error; + +use super::super::state::ConfiguratorApp; +use super::{PageBuilder, set_text_blocked}; + +/// Resolved RGBA for the swatch/dialog seed, `0.0..=1.0` channels. +pub(crate) type ResolvedColor = Option<(f64, f64, f64, f64)>; + +/// Formats a dialog selection the way the hex handler expects: `#rrggbb`, +/// with an alpha byte only when the color is actually translucent. +pub(crate) fn dialog_hex(rgba: >k::gdk::RGBA) -> String { + let channel = |value: f32| (value.clamp(0.0, 1.0) * 255.0).round() as u8; + let (r, g, b, a) = ( + channel(rgba.red()), + channel(rgba.green()), + channel(rgba.blue()), + channel(rgba.alpha()), + ); + if a == u8::MAX { + format!("#{r:02X}{g:02X}{b:02X}") + } else { + format!("#{r:02X}{g:02X}{b:02X}{a:02X}") + } +} + +/// Formats a selection for a four-component field, retaining `FF` so choosing +/// fully opaque replaces any translucent alpha already in the draft. +fn dialog_hex_with_alpha(rgba: >k::gdk::RGBA) -> String { + let channel = |value: f32| (value.clamp(0.0, 1.0) * 255.0).round() as u8; + let (r, g, b, a) = ( + channel(rgba.red()), + channel(rgba.green()), + channel(rgba.blue()), + channel(rgba.alpha()), + ); + format!("#{r:02X}{g:02X}{b:02X}{a:02X}") +} + +/// Marks a color field as holding text the save gate refuses. +pub(crate) fn mark_hex_error(widget: &impl IsA, text: &str) { + let has_error_class = widget.has_css_class("error"); + match hex_field_error(text) { + Some(message) => { + if !has_error_class { + widget.add_css_class("error"); + } + if widget.tooltip_text().as_deref() != Some(message) { + widget.set_tooltip_text(Some(message)); + } + } + None => { + if has_error_class { + widget.remove_css_class("error"); + widget.set_tooltip_text(None); + } + } + } +} + +/// Seeds a color dialog button from the model without reporting it as a pick. +/// +/// The comparison runs through the emitted hex so f32/f64 rounding cannot +/// ping-pong the dialog button and the model. +pub(crate) fn set_swatch_blocked( + button: >k::ColorDialogButton, + handler: &SignalHandlerId, + rgba: >k::gdk::RGBA, +) { + if dialog_hex(&button.rgba()) == dialog_hex(rgba) { + return; + } + button.block_signal(handler); + button.set_rgba(rgba); + button.unblock_signal(handler); +} + +/// Adds a color row to the current group: a hex entry (the model's editing +/// text for this picker) with a native color-dialog button as suffix. +/// Returns the row so callers can gate its visibility. +pub(crate) fn color_row( + page: &mut PageBuilder, + title: &str, + id: ColorPickerId, + resolved: impl Fn(&ConfiguratorApp) -> ResolvedColor + 'static, +) -> adw::EntryRow { + let row = adw::EntryRow::builder().title(title).build(); + let row_handler = { + let sender = page.sender(); + row.connect_changed(move |row| { + sender.input(Message::ColorPickerHexChanged(id, row.text().to_string())); + }) + }; + + let uses_alpha = id.uses_alpha(); + let dialog_button = gtk::ColorDialogButton::new(Some( + gtk::ColorDialog::builder().with_alpha(uses_alpha).build(), + )); + dialog_button.set_valign(gtk::Align::Center); + let button_handler = { + let sender = page.sender(); + dialog_button.connect_rgba_notify(move |button| { + sender.input(Message::ColorPickerHexChanged( + id, + if uses_alpha { + dialog_hex_with_alpha(&button.rgba()) + } else { + dialog_hex(&button.rgba()) + }, + )); + }) + }; + row.add_suffix(&dialog_button); + + page.custom(&row); + let handle = row.clone(); + page.bind(move |app, _summary| { + let text = app.color_picker_hex.get(&id).cloned().unwrap_or_default(); + set_text_blocked(&row, &row_handler, &text); + mark_hex_error(&row, &text); + if let Some((r, g, b, a)) = resolved(app) { + let rgba = gtk::gdk::RGBA::new(r as f32, g as f32, b as f32, a as f32); + set_swatch_blocked(&dialog_button, &button_handler, &rgba); + } + }); + handle +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn alpha_dialog_hex_keeps_an_explicit_opaque_byte() { + let rgba = gtk::gdk::RGBA::new(18.0 / 255.0, 52.0 / 255.0, 86.0 / 255.0, 1.0); + + assert_eq!(dialog_hex_with_alpha(&rgba), "#123456FF"); + assert_eq!(dialog_hex(&rgba), "#123456"); + } +} diff --git a/configurator/src/app/pages/daemon.rs b/configurator/src/app/pages/daemon.rs new file mode 100644 index 00000000..09ed832c --- /dev/null +++ b/configurator/src/app/pages/daemon.rs @@ -0,0 +1,896 @@ +//! Daemon page: background service, shortcut, and light-control setup. +//! +//! The one page that is a setup wizard rather than a list of preferences, +//! so its rows are custom widgets inside `AdwPreferencesGroup`s instead of +//! `PageBuilder` rows. Structure is built once; every label, button state, +//! and section visibility is a binding that reads the same +//! `DaemonRuntimeStatus` fields the Iced view read, and every button sends +//! the `DaemonAction` that view sent. + +use relm4::prelude::*; +use relm4::{adw, gtk}; + +use adw::prelude::*; + +use crate::messages::Message; +use crate::models::{ + DaemonAction, DaemonRuntimeStatus, LightShortcutApplyCapability, ShortcutApplyCapability, TabId, +}; + +use super::super::search::{AppSearchSummary, SearchArea}; +use super::super::state::ConfiguratorApp; +use super::{Binding, BuiltPage, set_text_blocked}; + +pub(super) fn build(sender: &ComponentSender) -> BuiltPage { + let page = adw::PreferencesPage::new(); + let mut bindings: Vec = Vec::new(); + + page.add(&overview_group(sender, &mut bindings)); + + for section in DaemonSection::ALL { + let group = match section { + DaemonSection::Install => install_group(sender, &mut bindings), + DaemonSection::Shortcut => shortcut_group(sender, &mut bindings), + DaemonSection::LightControls => light_controls_group(sender, &mut bindings), + DaemonSection::Start => start_group(sender, &mut bindings), + DaemonSection::TechnicalDetails => details_group(sender, &mut bindings), + }; + page.add(&group); + bindings.push(Box::new(move |app, summary| { + // The Iced view answered a status load with the details section + // alone: until the environment is known there is nothing for the + // setup steps to say. + let visible = daemon_section_visible(section, shown_areas(summary)) + && (section == DaemonSection::TechnicalDetails || app.daemon_status.is_some()); + set_visible(&group, visible); + })); + } + + BuiltPage { + widget: page.upcast(), + bindings, + } +} + +// ---- Sections ---------------------------------------------------------- + +/// The setup steps, in the order the Iced view pushed them. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum DaemonSection { + Install, + Shortcut, + LightControls, + Start, + TechnicalDetails, +} + +impl DaemonSection { + const ALL: [Self; 5] = [ + Self::Install, + Self::Shortcut, + Self::LightControls, + Self::Start, + Self::TechnicalDetails, + ]; +} + +/// Which daemon search areas the current query left on screen. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct ShownAreas { + status: bool, + service: bool, + shortcut: bool, + light: bool, +} + +fn daemon_section_visible(section: DaemonSection, shown: ShownAreas) -> bool { + match section { + DaemonSection::Install | DaemonSection::Start => shown.service, + DaemonSection::Shortcut => shown.shortcut, + DaemonSection::LightControls => shown.light, + DaemonSection::TechnicalDetails => shown.status || shown.service, + } +} + +fn shown_areas(summary: &AppSearchSummary) -> ShownAreas { + let matches = |area| { + !summary.is_active() + || summary + .tab(TabId::Daemon) + .is_some_and(|tab| tab.area_matches(area)) + }; + ShownAreas { + status: matches(SearchArea::DaemonStatus), + service: matches(SearchArea::DaemonService), + shortcut: matches(SearchArea::DaemonShortcut), + light: matches(SearchArea::DaemonLightControls), + } +} + +// ---- Groups ------------------------------------------------------------ + +fn overview_group( + sender: &ComponentSender, + bindings: &mut Vec, +) -> adw::PreferencesGroup { + let group = adw::PreferencesGroup::builder() + .title("Background Mode") + .description("Run wayscriber in the background and toggle it with a keyboard shortcut.") + .build(); + + let body = column_box(); + let status_row = row_box(); + let status_label = body_label(""); + status_label.add_css_class("heading"); + let refresh = action_button("Refresh", DaemonAction::RefreshStatus, sender); + status_row.append(&status_label); + status_row.append(&refresh); + body.append(&status_row); + + let feedback_label = body_label(""); + body.append(&feedback_label); + let busy_label = caption_label("Working..."); + body.append(&busy_label); + let loading_label = hint_label("Checking your system and background service status..."); + body.append(&loading_label); + group.add(&body); + + bindings.push(Box::new(move |app, summary| { + let (text, tone) = overall_status(app.daemon_status.as_ref()); + set_label(&status_label, text); + apply_tone(&status_label, tone); + set_visible(&status_row, shown_areas(summary).status); + set_sensitive(&refresh, !app.daemon_busy); + + match app.daemon_feedback.as_deref() { + Some(feedback) => { + set_label(&feedback_label, feedback); + apply_tone(&feedback_label, feedback_tone(feedback)); + set_visible(&feedback_label, true); + } + None => set_visible(&feedback_label, false), + } + + set_visible(&busy_label, app.daemon_busy); + set_visible(&loading_label, app.daemon_status.is_none()); + })); + + group +} + +fn install_group( + sender: &ComponentSender, + bindings: &mut Vec, +) -> adw::PreferencesGroup { + let group = adw::PreferencesGroup::builder() + .title("Step 1 \u{2014} Install the service") + .description("Install wayscriber as a background service.") + .build(); + + let row = row_box(); + let state_label = body_label(""); + let install = action_button( + install_button_label(false), + DaemonAction::InstallOrUpdateService, + sender, + ); + row.append(&state_label); + row.append(&install); + group.add(&row); + + bindings.push(Box::new(move |app, _summary| { + let installed = service_installed(app); + let (text, tone) = install_status(installed); + set_label(&state_label, text); + apply_tone(&state_label, tone); + set_button_label(&install, install_button_label(installed)); + set_sensitive(&install, !app.daemon_busy); + })); + + group +} + +fn shortcut_group( + sender: &ComponentSender, + bindings: &mut Vec, +) -> adw::PreferencesGroup { + let group = adw::PreferencesGroup::builder() + .title("Step 2 \u{2014} Set your shortcut") + .build(); + + let locked_label = hint_label("Install the background service first, then set your shortcut."); + group.add(&locked_label); + + let body = column_box(); + body.append(&body_label( + "Choose a keyboard shortcut to toggle drawing on/off.", + )); + body.append(&hint_label( + "The shortcut takes effect after the background service is installed and running.", + )); + let configured_label = hint_label(""); + body.append(&configured_label); + let manual_label = warning_label( + "Automatic shortcut setup is unavailable here. Add a manual keybind for `wayscriber --daemon-toggle`.", + ); + body.append(&manual_label); + + let entry = gtk::Entry::builder().hexpand(true).build(); + let entry_handler = { + let sender = sender.clone(); + entry.connect_changed(move |entry| { + sender.input(Message::DaemonShortcutInputChanged( + entry.text().to_string(), + )); + }) + }; + body.append(&entry); + + let apply = action_button("Apply Shortcut", DaemonAction::ApplyShortcut, sender); + apply.add_css_class("suggested-action"); + body.append(&apply); + group.add(&body); + + bindings.push(Box::new(move |app, _summary| { + let installed = service_installed(app); + set_visible(&locked_label, !installed); + set_visible(&body, installed); + + let capability = app + .daemon_status + .as_ref() + .map(|status| status.shortcut_apply_capability); + let placeholder = shortcut_placeholder(capability); + if entry.placeholder_text().as_deref() != Some(placeholder) { + entry.set_placeholder_text(Some(placeholder)); + } + set_text_blocked(&entry, &entry_handler, &app.daemon_shortcut_input); + + match app + .daemon_status + .as_ref() + .and_then(|status| status.configured_shortcut.as_deref()) + { + Some(configured) => { + set_label( + &configured_label, + &format!("Current shortcut: {configured}"), + ); + set_visible(&configured_label, true); + } + None => set_visible(&configured_label, false), + } + + let manual = capability == Some(ShortcutApplyCapability::Manual); + set_visible(&manual_label, manual); + set_sensitive(&apply, !app.daemon_busy && !manual); + })); + + group +} + +fn light_controls_group( + sender: &ComponentSender, + bindings: &mut Vec, +) -> adw::PreferencesGroup { + let group = adw::PreferencesGroup::builder() + .title("Light passthrough controls") + .description("Install global controls for light passthrough and quick drawing.") + .build(); + + let path_label = hint_label(""); + group.add(&path_label); + + let native_body = column_box(); + let service_warning = warning_label( + "Install the background service first so these bindings have a daemon to control.", + ); + native_body.append(&service_warning); + let native_row = row_box(); + let state_label = body_label(""); + let install = action_button( + "Install Hyprland Light Controls", + DaemonAction::ApplyLightControls, + sender, + ); + install.add_css_class("suggested-action"); + native_row.append(&state_label); + native_row.append(&install); + native_body.append(&native_row); + group.add(&native_body); + + let manual_label = warning_label( + "Automatic light controls setup is unavailable here. Add compositor bindings for `wayscriber --light-toggle` and `wayscriber --light-draw-toggle`.", + ); + group.add(&manual_label); + + bindings.push(Box::new(move |app, _summary| { + let status = app.daemon_status.as_ref(); + match status.and_then(|status| status.light_controls_config_path.as_deref()) { + Some(path) => { + set_label(&path_label, &format!("Hyprland include: {path}")); + set_visible(&path_label, true); + } + None => set_visible(&path_label, false), + } + + let capability = status + .map(|status| status.light_shortcut_apply_capability) + .unwrap_or(LightShortcutApplyCapability::Manual); + let native = capability == LightShortcutApplyCapability::HyprlandNative; + set_visible(&native_body, native); + set_visible(&manual_label, !native); + + let installed = service_installed(app); + set_visible(&service_warning, !installed); + let (text, tone) = + light_controls_status(status.is_some_and(|status| status.light_controls_configured)); + set_label(&state_label, text); + apply_tone(&state_label, tone); + set_sensitive(&install, !app.daemon_busy && installed); + })); + + group +} + +fn start_group( + sender: &ComponentSender, + bindings: &mut Vec, +) -> adw::PreferencesGroup { + let group = adw::PreferencesGroup::builder() + .title("Step 3 \u{2014} Start the service") + .build(); + + let locked_label = hint_label("Install the background service first."); + group.add(&locked_label); + + let body = column_box(); + body.append(&body_label("Enable and start the background service.")); + let state_label = body_label(""); + body.append(&state_label); + + let running_row = row_box(); + let restart = action_button("Restart", DaemonAction::RestartService, sender); + let stop = action_button( + "Stop & Disable", + DaemonAction::StopAndDisableService, + sender, + ); + running_row.append(&restart); + running_row.append(&stop); + body.append(&running_row); + + let start = action_button("Start", DaemonAction::EnableAndStartService, sender); + start.add_css_class("suggested-action"); + body.append(&start); + group.add(&body); + + bindings.push(Box::new(move |app, _summary| { + let installed = service_installed(app); + set_visible(&locked_label, !installed); + set_visible(&body, installed); + + let status = app.daemon_status.as_ref(); + let running = status.is_some_and(|status| status.service_active); + let enabled = status.is_some_and(|status| status.service_enabled); + let (text, tone) = service_status(running, enabled); + set_label(&state_label, text); + apply_tone(&state_label, tone); + + set_visible(&running_row, running); + set_visible(&start, !running); + for button in [&restart, &stop, &start] { + set_sensitive(button, !app.daemon_busy); + } + })); + + group +} + +fn details_group( + sender: &ComponentSender, + bindings: &mut Vec, +) -> adw::PreferencesGroup { + let group = adw::PreferencesGroup::builder().title("Details").build(); + + let body = column_box(); + let detecting_label = hint_label("Detecting environment..."); + let desktop_label = hint_label(""); + let backend_label = hint_label(""); + let shortcut_capability_label = hint_label(""); + let light_capability_label = hint_label(""); + let service_file_label = hint_label(""); + let light_controls_label = hint_label(""); + let missing_tools_label = warning_label(""); + for label in [ + &detecting_label, + &desktop_label, + &backend_label, + &shortcut_capability_label, + &light_capability_label, + &service_file_label, + &light_controls_label, + &missing_tools_label, + ] { + body.append(label); + } + + let refresh = action_button("Refresh", DaemonAction::RefreshStatus, sender); + body.append(&refresh); + group.add(&body); + + bindings.push(Box::new(move |app, _summary| { + set_sensitive(&refresh, !app.daemon_busy); + let Some(status) = app.daemon_status.as_ref() else { + set_visible(&detecting_label, true); + for label in [ + &desktop_label, + &backend_label, + &shortcut_capability_label, + &light_capability_label, + &service_file_label, + &light_controls_label, + &missing_tools_label, + ] { + set_visible(label, false); + } + return; + }; + + set_visible(&detecting_label, false); + for (label, text) in [ + ( + &desktop_label, + format!("Desktop: {}", status.desktop.label()), + ), + ( + &backend_label, + status.shortcut_backend.friendly_label().to_string(), + ), + ( + &shortcut_capability_label, + status + .shortcut_apply_capability + .friendly_label() + .to_string(), + ), + ( + &light_capability_label, + status + .light_shortcut_apply_capability + .friendly_label() + .to_string(), + ), + ] { + set_label(label, &text); + set_visible(label, true); + } + + for (label, text) in [ + ( + &service_file_label, + status + .service_unit_path + .as_deref() + .map(|path| format!("Service file: {path}")), + ), + (&light_controls_label, light_controls_details(status)), + (&missing_tools_label, missing_tools(status)), + ] { + match text { + Some(text) => { + set_label(label, &text); + set_visible(label, true); + } + None => set_visible(label, false), + } + } + })); + + group +} + +// ---- Status wording ---------------------------------------------------- + +/// Emphasis for a status line, mapped to the Adwaita state classes that +/// stand in for the Iced view's literal colors. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Tone { + Neutral, + Positive, + Caution, + Negative, +} + +impl Tone { + const CLASSES: [&'static str; 4] = ["dim-label", "success", "warning", "error"]; + + fn css_class(self) -> &'static str { + match self { + Self::Neutral => "dim-label", + Self::Positive => "success", + Self::Caution => "warning", + Self::Negative => "error", + } + } +} + +fn overall_status(status: Option<&DaemonRuntimeStatus>) -> (&'static str, Tone) { + match status { + None => ("Status: Detecting...", Tone::Neutral), + Some(status) if status.service_active => ("Status: Running", Tone::Positive), + Some(status) if status.service_installed => { + ("Status: Installed, not running", Tone::Caution) + } + Some(_) => ("Status: Not installed", Tone::Neutral), + } +} + +fn feedback_tone(feedback: &str) -> Tone { + let feedback = feedback.to_ascii_lowercase(); + if feedback.contains("failed") || feedback.contains("error") { + Tone::Negative + } else { + Tone::Positive + } +} + +fn install_status(installed: bool) -> (&'static str, Tone) { + if installed { + ("Installed \u{2713}", Tone::Positive) + } else { + ("Not installed", Tone::Neutral) + } +} + +fn install_button_label(installed: bool) -> &'static str { + if installed { + "Update Service" + } else { + "Install Service" + } +} + +fn light_controls_status(configured: bool) -> (&'static str, Tone) { + if configured { + ("Configured \u{2713}", Tone::Positive) + } else { + ("Not configured", Tone::Neutral) + } +} + +fn service_status(running: bool, enabled: bool) -> (&'static str, Tone) { + match (running, enabled) { + (true, true) => ("Running \u{2713}", Tone::Positive), + (true, false) => ("Running (not enabled)", Tone::Caution), + (false, true) => ("Enabled, not running", Tone::Caution), + (false, false) => ("Stopped and disabled", Tone::Neutral), + } +} + +fn shortcut_placeholder(capability: Option) -> &'static str { + match capability { + Some(ShortcutApplyCapability::GnomeCustomShortcut) => "e.g. Super+G or g", + Some(ShortcutApplyCapability::PortalServiceDropIn) => "e.g. Ctrl+Shift+G or g", + _ => "e.g. Ctrl+Shift+G", + } +} + +fn light_controls_details(status: &DaemonRuntimeStatus) -> Option { + let path = status.light_controls_config_path.as_deref()?; + Some(if status.light_controls_configured { + format!("Light controls: configured at {path}") + } else { + format!("Light controls include: {path}") + }) +} + +/// The tool availability line, shown only when something is missing. +fn missing_tools(status: &DaemonRuntimeStatus) -> Option { + let mut missing = Vec::new(); + if !status.systemctl_available { + missing.push("systemctl"); + } + if !status.gsettings_available { + missing.push("gsettings"); + } + (!missing.is_empty()).then(|| format!("Missing tools: {}", missing.join(", "))) +} + +fn service_installed(app: &ConfiguratorApp) -> bool { + app.daemon_status + .as_ref() + .is_some_and(|status| status.service_installed) +} + +// ---- Widget helpers ---------------------------------------------------- + +fn column_box() -> gtk::Box { + gtk::Box::builder() + .orientation(gtk::Orientation::Vertical) + .spacing(8) + .build() +} + +fn row_box() -> gtk::Box { + gtk::Box::builder() + .orientation(gtk::Orientation::Horizontal) + .spacing(12) + .build() +} + +fn body_label(text: &str) -> gtk::Label { + gtk::Label::builder() + .label(text) + .xalign(0.0) + .wrap(true) + .halign(gtk::Align::Start) + .valign(gtk::Align::Center) + .build() +} + +fn caption_label(text: &str) -> gtk::Label { + let label = body_label(text); + label.add_css_class("caption"); + label +} + +fn hint_label(text: &str) -> gtk::Label { + let label = caption_label(text); + label.add_css_class("dim-label"); + label +} + +fn warning_label(text: &str) -> gtk::Label { + let label = caption_label(text); + label.add_css_class("warning"); + label +} + +fn action_button( + label: &str, + action: DaemonAction, + sender: &ComponentSender, +) -> gtk::Button { + let button = gtk::Button::builder() + .label(label) + .halign(gtk::Align::Start) + .valign(gtk::Align::Center) + .build(); + let sender = sender.clone(); + button.connect_clicked(move |_| sender.input(Message::DaemonActionRequested(action))); + button +} + +fn set_label(label: >k::Label, text: &str) { + if label.label() != text { + label.set_label(text); + } +} + +fn set_button_label(button: >k::Button, text: &str) { + if button.label().as_deref() != Some(text) { + button.set_label(text); + } +} + +/// Writes the widget's own visibility flag, never `is_visible`: a child of a +/// hidden group reports invisible while its own flag still says otherwise, +/// and skipping the write there would leak the stale state the moment the +/// group comes back. +fn set_visible(widget: &impl IsA, visible: bool) { + if widget.get_visible() != visible { + widget.set_visible(visible); + } +} + +fn set_sensitive(widget: &impl IsA, sensitive: bool) { + if widget.is_sensitive() != sensitive { + widget.set_sensitive(sensitive); + } +} + +fn apply_tone(widget: &impl IsA, tone: Tone) { + let wanted = tone.css_class(); + for class in Tone::CLASSES { + let has_class = widget.has_css_class(class); + if class == wanted { + if !has_class { + widget.add_css_class(class); + } + } else if has_class { + widget.remove_css_class(class); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::models::{DesktopEnvironment, ShortcutBackend}; + + fn shown_all() -> ShownAreas { + ShownAreas { + status: true, + service: true, + shortcut: true, + light: true, + } + } + + fn visible_sections(shown: ShownAreas) -> Vec { + DaemonSection::ALL + .into_iter() + .filter(|section| daemon_section_visible(*section, shown)) + .collect() + } + + fn test_status() -> DaemonRuntimeStatus { + DaemonRuntimeStatus { + desktop: DesktopEnvironment::Hyprland, + shortcut_backend: ShortcutBackend::Manual, + shortcut_apply_capability: ShortcutApplyCapability::Manual, + light_shortcut_apply_capability: LightShortcutApplyCapability::HyprlandNative, + systemctl_available: true, + gsettings_available: true, + service_installed: false, + service_enabled: false, + service_active: false, + service_unit_path: None, + configured_shortcut: None, + light_controls_configured: false, + light_controls_config_path: None, + } + } + + #[test] + fn daemon_sections_keep_default_setup_order() { + assert_eq!( + visible_sections(shown_all()), + vec![ + DaemonSection::Install, + DaemonSection::Shortcut, + DaemonSection::LightControls, + DaemonSection::Start, + DaemonSection::TechnicalDetails, + ], + ); + } + + #[test] + fn service_area_keeps_install_start_and_details() { + assert_eq!( + visible_sections(ShownAreas { + status: false, + service: true, + shortcut: false, + light: false, + }), + vec![ + DaemonSection::Install, + DaemonSection::Start, + DaemonSection::TechnicalDetails, + ], + ); + } + + #[test] + fn status_area_alone_keeps_only_details() { + assert_eq!( + visible_sections(ShownAreas { + status: true, + service: false, + shortcut: false, + light: false, + }), + vec![DaemonSection::TechnicalDetails], + ); + } + + #[test] + fn shortcut_area_alone_keeps_only_the_shortcut_step() { + assert_eq!( + visible_sections(ShownAreas { + status: false, + service: false, + shortcut: true, + light: false, + }), + vec![DaemonSection::Shortcut], + ); + } + + #[test] + fn overall_status_follows_service_state() { + assert_eq!(overall_status(None).0, "Status: Detecting..."); + + let mut status = test_status(); + assert_eq!(overall_status(Some(&status)).0, "Status: Not installed"); + + status.service_installed = true; + assert_eq!( + overall_status(Some(&status)), + ("Status: Installed, not running", Tone::Caution) + ); + + status.service_active = true; + assert_eq!( + overall_status(Some(&status)), + ("Status: Running", Tone::Positive) + ); + } + + #[test] + fn feedback_tone_flags_failures() { + assert_eq!( + feedback_tone("Background setup action failed"), + Tone::Negative + ); + assert_eq!(feedback_tone("gsettings ERROR: nope"), Tone::Negative); + assert_eq!( + feedback_tone("Background mode status loaded."), + Tone::Positive + ); + } + + #[test] + fn service_status_separates_running_from_enabled() { + assert_eq!(service_status(true, true).0, "Running \u{2713}"); + assert_eq!(service_status(true, false).0, "Running (not enabled)"); + assert_eq!(service_status(false, true).0, "Enabled, not running"); + assert_eq!(service_status(false, false).0, "Stopped and disabled"); + } + + #[test] + fn shortcut_placeholder_follows_apply_capability() { + assert_eq!( + shortcut_placeholder(Some(ShortcutApplyCapability::GnomeCustomShortcut)), + "e.g. Super+G or g" + ); + assert_eq!( + shortcut_placeholder(Some(ShortcutApplyCapability::PortalServiceDropIn)), + "e.g. Ctrl+Shift+G or g" + ); + assert_eq!( + shortcut_placeholder(Some(ShortcutApplyCapability::Manual)), + "e.g. Ctrl+Shift+G" + ); + assert_eq!(shortcut_placeholder(None), "e.g. Ctrl+Shift+G"); + } + + #[test] + fn missing_tools_only_reports_absent_ones() { + let mut status = test_status(); + assert_eq!(missing_tools(&status), None); + + status.gsettings_available = false; + assert_eq!( + missing_tools(&status).as_deref(), + Some("Missing tools: gsettings") + ); + + status.systemctl_available = false; + assert_eq!( + missing_tools(&status).as_deref(), + Some("Missing tools: systemctl, gsettings") + ); + } + + #[test] + fn light_controls_details_reflect_configured_state() { + let mut status = test_status(); + assert_eq!(light_controls_details(&status), None); + + status.light_controls_config_path = Some("/tmp/wayscriber.conf".to_string()); + assert_eq!( + light_controls_details(&status).as_deref(), + Some("Light controls include: /tmp/wayscriber.conf") + ); + + status.light_controls_configured = true; + assert_eq!( + light_controls_details(&status).as_deref(), + Some("Light controls: configured at /tmp/wayscriber.conf") + ); + } +} diff --git a/configurator/src/app/pages/drawing.rs b/configurator/src/app/pages/drawing.rs new file mode 100644 index 00000000..526eacff --- /dev/null +++ b/configurator/src/app/pages/drawing.rs @@ -0,0 +1,1023 @@ +//! Drawing page: default color, quick colors, drawing defaults, per-button +//! drag tool mapping, and font controls. +//! +//! Two shapes here go past the plain row helpers. A section that only applies +//! in one mode — named versus RGB color, the open drag button — is a boxed +//! list added to its area's group, so the group keeps owning search +//! visibility while the section's own binding answers only the model +//! question. Quick colors are a dynamic list: rows are rebuilt when the entry +//! count changes and refreshed in place otherwise, which keeps the row the +//! user is typing in alive. + +use relm4::prelude::*; +use relm4::{adw, gtk}; + +use adw::prelude::*; +use gtk::glib; + +use wayscriber::config::{DragButtonConfig, QUICK_COLOR_RENDER_LIMIT, QuickColorSlot}; +use wayscriber::draw::Color; + +use crate::messages::Message; +use crate::models::color::ColorInput; +use crate::models::util::format_float; +use crate::models::{ + ColorMode, ColorPickerId, DragColorOption, DragMouseButton, DragToolField, DragToolOption, + EraserModeOption, FontStyleOption, FontWeightOption, NamedColorOption, TabId, TextField, + ToggleField, +}; + +use super::super::search::{AppSearchSummary, SearchArea}; +use super::super::state::ConfiguratorApp; +use super::color_rows::{ResolvedColor, color_row, dialog_hex, mark_hex_error, set_swatch_blocked}; +use super::{BuiltPage, PageBuilder, set_selected_blocked, set_text_blocked}; + +/// Mouse buttons that carry a drag mapping section, in the order the old +/// view listed them. +const DRAG_BUTTONS: [DragMouseButton; 3] = [ + DragMouseButton::Left, + DragMouseButton::Right, + DragMouseButton::Middle, +]; + +/// Modifier combinations each drag mapping section binds. +const DRAG_FIELDS: [DragToolField; 5] = [ + DragToolField::Drag, + DragToolField::ShiftDrag, + DragToolField::CtrlDrag, + DragToolField::CtrlShiftDrag, + DragToolField::TabDrag, +]; + +const COLOR_MODES: [ColorMode; 2] = [ColorMode::Named, ColorMode::Rgb]; + +pub(super) fn build(sender: &ComponentSender) -> BuiltPage { + let mut page = PageBuilder::new(sender, TabId::Drawing); + + build_default_color(&mut page); + build_quick_colors(&mut page); + build_defaults(&mut page); + build_drag_mapping(&mut page); + build_font(&mut page); + + page.finish() +} + +// --------------------------------------------------------------------------- +// Default color +// --------------------------------------------------------------------------- + +fn build_default_color(page: &mut PageBuilder) { + page.group_in_area("Default color", SearchArea::DrawingColor) + .combo_row( + "Color mode", + "A palette name or hex string, or explicit RGB components.", + COLOR_MODES.to_vec(), + vec!["Named color".to_string(), "RGB color".to_string()], + |app| app.draft.drawing_color.mode, + Message::ColorModeChanged, + ); + + let named = conditional_section(page, |app| app.draft.drawing_color.mode == ColorMode::Named); + section_combo_row( + page, + &named, + "Named color", + NamedColorOption::list(), + named_color_labels(), + |app| app.draft.drawing_color.selected_named, + Message::NamedColorSelected, + ); + section_entry_row( + page, + &named, + "Custom color name", + |app| app.draft.drawing_color.name.clone(), + |value| Message::TextChanged(TextField::DrawingColorName, value), + |app| color_name_error(&app.draft.drawing_color, "Unknown color name"), + ); + + // Only in RGB mode: in Named mode the save serializes the named value, + // so a visible RGB edit here would be silently discarded. + page.group_in_area_when("RGB color", SearchArea::DrawingColor, |app| { + app.draft.drawing_color.mode == ColorMode::Rgb + }); + color_row(page, "Custom color", ColorPickerId::DrawingColor, |app| { + resolved(app.draft.drawing_color.preview_color()) + }); +} + +/// The error the old view showed under a custom color name that resolves to +/// nothing, `None` while the field is empty or usable. +fn color_name_error(color: &ColorInput, message: &str) -> Option { + let unresolved = color.preview_color().is_none() && !color.name.trim().is_empty(); + unresolved.then(|| message.to_string()) +} + +fn named_color_labels() -> Vec { + NamedColorOption::list() + .iter() + .map(|option| option.label().to_string()) + .collect() +} + +fn resolved(color: Option) -> ResolvedColor { + color.map(|color| (color.r, color.g, color.b, color.a)) +} + +// --------------------------------------------------------------------------- +// Quick colors +// --------------------------------------------------------------------------- + +fn build_quick_colors(page: &mut PageBuilder) { + page.group_in_area("Quick colors", SearchArea::DrawingColor); + + // An activatable ActionRow rather than AdwButtonRow: ButtonRow needs + // libadwaita 1.6 and the crate's feature floor is 1.4 (Ubuntu 24.04). + let add = adw::ActionRow::builder() + .title("Add color") + .activatable(true) + .build(); + add.add_prefix(>k::Image::from_icon_name("list-add-symbolic")); + { + let sender = page.sender(); + add.connect_activated(move |_| sender.input(Message::QuickColorAdded)); + } + page.custom(&add); + + let warning = gtk::Label::builder() + .label(format!( + "Only the first {QUICK_COLOR_RENDER_LIMIT} quick colors are shown in toolbar and radial menus." + )) + .wrap(true) + .xalign(0.0) + .margin_top(6) + .css_classes(["warning"]) + .build(); + page.custom(&warning); + page.bind(move |app, _summary| { + let over_limit = app.draft.drawing_quick_colors.entries.len() > QUICK_COLOR_RENDER_LIMIT; + set_visible_if_changed(&warning, over_limit); + }); + + let list = boxed_list(); + list.set_margin_top(6); + page.custom(&list); + let sender = page.sender(); + // Each built row owns the layout that produced it and its typed refresh. + let mut rows: Vec = Vec::new(); + page.bind(move |app, _summary| { + let layouts = quick_color_layouts(app); + if !rows + .iter() + .map(|row| row.layout) + .eq(layouts.iter().copied()) + { + rows = rebuild_quick_colors(&list, &layouts, &sender); + } + for ((index, entry), row) in app + .draft + .drawing_quick_colors + .entries + .iter() + .enumerate() + .zip(rows.iter()) + { + let values = QuickColorValues { + label: &entry.label, + name: &entry.color.name, + hex: picker_hex(app, ColorPickerId::QuickColor(index)), + named: entry.color.selected_named, + preview: resolved(entry.color.preview_color()), + summary: entry.color.summary(), + }; + (row.refresh)(&values); + } + }); +} + +/// Everything a quick color row shows before any text is written into it. +/// +/// This is the whole rebuild trigger: the binding compares the list of these +/// it built against the list the model asks for now, so a control added here +/// rebuilds on its own value. Everything the user types — and the named-color +/// choice, which a typed name moves on its own — stays out, because rebuilding +/// a row takes the caret with it. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct QuickColorLayout { + mode: ColorMode, + can_move_up: bool, + can_move_down: bool, + can_remove: bool, +} + +fn quick_color_layouts(app: &ConfiguratorApp) -> Vec { + let count = app.draft.drawing_quick_colors.entries.len(); + app.draft + .drawing_quick_colors + .entries + .iter() + .enumerate() + .map(|(index, entry)| QuickColorLayout { + mode: entry.color.mode, + can_move_up: index > 0, + can_move_down: index + 1 < count, + // The draft refuses to drop below the eight slots the shortcuts + // bind. + can_remove: count > QuickColorSlot::ALL.len(), + }) + .collect() +} + +/// One quick color row's values: everything the layout deliberately left out. +struct QuickColorValues<'a> { + label: &'a str, + name: &'a str, + hex: &'a str, + named: NamedColorOption, + /// The color the entry resolves to, `None` when it resolves to nothing — + /// which is also what makes the name field an error. + preview: ResolvedColor, + summary: String, +} + +/// One row's refresh: built beside its row, so it owns that row's typed widget +/// handles and the signal handler ids guarding each write. +type QuickColorRowRefresh = Box)>; + +struct BoundQuickColorRow { + layout: QuickColorLayout, + refresh: QuickColorRowRefresh, +} + +fn rebuild_quick_colors( + list: >k::ListBox, + layouts: &[QuickColorLayout], + sender: &ComponentSender, +) -> Vec { + // Draining the list, not walking it for a control: the rows that replace + // these carry their own refresh closures. + while let Some(child) = list.first_child() { + list.remove(&child); + } + + let mut rows = Vec::with_capacity(layouts.len()); + for (index, layout) in layouts.iter().enumerate() { + let built = build_quick_color_row(index, *layout, sender); + list.append(&built.row); + rows.push(BoundQuickColorRow { + layout: *layout, + refresh: built.refresh, + }); + } + rows +} + +/// One quick color entry, built with everything its layout decides already in +/// place, and paired with the closure that writes the rest. +/// +/// The closure owns the row's widgets and the ids of their handlers, so it +/// writes them blocked: the hex path reports a programmatic write as a user +/// pick, and that pick rewrites the entry's components and wipes the status +/// line the user was reading. +struct QuickColorRow { + row: adw::ExpanderRow, + refresh: QuickColorRowRefresh, +} + +fn build_quick_color_row( + index: usize, + layout: QuickColorLayout, + sender: &ComponentSender, +) -> QuickColorRow { + let row = adw::ExpanderRow::builder() + .title(format!("Color {}", index + 1)) + .build(); + + let up = icon_button("go-up-symbolic", "Move up"); + up.set_sensitive(layout.can_move_up); + { + let sender = sender.clone(); + up.connect_clicked(move |_| sender.input(Message::QuickColorMoved(index, -1))); + } + let down = icon_button("go-down-symbolic", "Move down"); + down.set_sensitive(layout.can_move_down); + { + let sender = sender.clone(); + down.connect_clicked(move |_| sender.input(Message::QuickColorMoved(index, 1))); + } + let remove = icon_button("user-trash-symbolic", "Remove"); + remove.set_sensitive(layout.can_remove); + { + let sender = sender.clone(); + remove.connect_clicked(move |_| sender.input(Message::QuickColorRemoved(index))); + } + row.add_suffix(&up); + row.add_suffix(&down); + row.add_suffix(&remove); + + let label = adw::EntryRow::builder().title("Label").build(); + let label_handler = { + let sender = sender.clone(); + label.connect_changed(move |row| { + sender.input(Message::TextChanged( + TextField::QuickColorLabel(index), + row.text().to_string(), + )); + }) + }; + row.add_row(&label); + + // The mode is part of the fingerprint, so it is selected before the + // handler exists and never written again behind the user's back. + let mode = combo_row_widget( + "Color mode", + &["Named or hex".to_string(), "RGB".to_string()], + ); + select_if_changed(&mode, &COLOR_MODES, layout.mode); + connect_combo(&mode, sender.clone(), COLOR_MODES.to_vec(), move |value| { + Message::QuickColorModeChanged(index, value) + }); + row.add_row(&mode); + + let named_options = NamedColorOption::list(); + let named = combo_row_widget("Named color", &named_color_labels()); + named.set_visible(layout.mode == ColorMode::Named); + let named_handler = { + let sender = sender.clone(); + let options = named_options.clone(); + named.connect_selected_notify(move |row| { + if let Some(option) = options.get(row.selected() as usize) { + sender.input(Message::QuickNamedColorSelected(index, *option)); + } + }) + }; + row.add_row(&named); + + let name = adw::EntryRow::builder() + .title("Color name or #RRGGBB[AA]") + .build(); + name.set_visible(layout.mode == ColorMode::Named); + let name_handler = { + let sender = sender.clone(); + name.connect_changed(move |row| { + sender.input(Message::TextChanged( + TextField::QuickColorName(index), + row.text().to_string(), + )); + }) + }; + row.add_row(&name); + + let picker = ColorPickerId::QuickColor(index); + let hex = adw::EntryRow::builder().title("Custom color").build(); + hex.set_visible(layout.mode == ColorMode::Rgb); + let hex_handler = { + let sender = sender.clone(); + hex.connect_changed(move |row| { + sender.input(Message::ColorPickerHexChanged( + picker, + row.text().to_string(), + )); + }) + }; + let swatch = color_dialog_button(); + let swatch_handler = { + let sender = sender.clone(); + swatch.connect_rgba_notify(move |button| { + sender.input(Message::ColorPickerHexChanged( + picker, + dialog_hex(&button.rgba()), + )); + }) + }; + hex.add_suffix(&swatch); + row.add_row(&hex); + + let expander = row.clone(); + let refresh: QuickColorRowRefresh = Box::new(move |values| { + let subtitle = format!("{} / {}", values.label.trim(), values.summary); + if expander.subtitle() != subtitle { + expander.set_subtitle(&subtitle); + } + + set_text_blocked(&label, &label_handler, values.label); + set_text_blocked(&name, &name_handler, values.name); + set_error_if_changed(&name, name_error(values)); + set_text_blocked(&hex, &hex_handler, values.hex); + // The same predicate the save gate counts with, so a field styled + // clean can never be one the save refuses. + mark_hex_error(&hex, values.hex); + + if let Some(position) = named_options + .iter() + .position(|option| *option == values.named) + { + set_selected_blocked(&named, &named_handler, position as u32); + } + + if let Some((r, g, b, a)) = values.preview { + let rgba = gtk::gdk::RGBA::new(r as f32, g as f32, b as f32, a as f32); + set_swatch_blocked(&swatch, &swatch_handler, &rgba); + } + }); + + QuickColorRow { row, refresh } +} + +/// The error the old view showed under a quick color name that resolves to +/// nothing, read off the values the row was handed. +fn name_error(values: &QuickColorValues<'_>) -> Option { + let unresolved = values.preview.is_none() && !values.name.trim().is_empty(); + unresolved.then(|| "Use a known color name, #RRGGBB, or #RRGGBBAA for alpha".to_string()) +} + +fn picker_hex(app: &ConfiguratorApp, id: ColorPickerId) -> &str { + app.color_picker_hex.get(&id).map_or("", String::as_str) +} + +// --------------------------------------------------------------------------- +// Drawing defaults +// --------------------------------------------------------------------------- + +fn build_defaults(page: &mut PageBuilder) { + page.group_in_area("Drawing defaults", SearchArea::DrawingDefaults) + .entry_row_validated( + "Thickness (px)", + |app| app.draft.drawing_default_thickness.clone(), + |value| Message::TextChanged(TextField::DrawingThickness, value), + |app| validate_f64_range(&app.draft.drawing_default_thickness, 1.0, 50.0), + ) + .entry_row_validated( + "Font size (pt)", + |app| app.draft.drawing_default_font_size.clone(), + |value| Message::TextChanged(TextField::DrawingFontSize, value), + |app| validate_f64_range(&app.draft.drawing_default_font_size, 8.0, 72.0), + ) + .entry_row_validated( + "Polygon sides", + |app| app.draft.drawing_polygon_sides.clone(), + |value| Message::TextChanged(TextField::DrawingPolygonSides, value), + |app| validate_usize_range(&app.draft.drawing_polygon_sides, 3, 12), + ) + .entry_row_validated( + "Eraser size (px)", + |app| app.draft.drawing_default_eraser_size.clone(), + |value| Message::TextChanged(TextField::DrawingEraserSize, value), + |app| validate_f64_range(&app.draft.drawing_default_eraser_size, 1.0, 50.0), + ) + .combo_row( + "Eraser mode", + "", + EraserModeOption::list(), + EraserModeOption::list() + .iter() + .map(|option| option.label().to_string()) + .collect(), + |app| app.draft.drawing_default_eraser_mode, + Message::EraserModeChanged, + ) + .entry_row_validated( + "Marker opacity (0.05-0.9)", + |app| app.draft.drawing_marker_opacity.clone(), + |value| Message::TextChanged(TextField::DrawingMarkerOpacity, value), + |app| validate_f64_range(&app.draft.drawing_marker_opacity, 0.05, 0.9), + ) + .entry_row_validated( + "Undo stack limit", + |app| app.draft.drawing_undo_stack_limit.clone(), + |value| Message::TextChanged(TextField::DrawingUndoStackLimit, value), + |app| validate_usize_range(&app.draft.drawing_undo_stack_limit, 10, 1000), + ) + .entry_row_validated( + "Hit-test tolerance (px)", + |app| app.draft.drawing_hit_test_tolerance.clone(), + |value| Message::TextChanged(TextField::DrawingHitTestTolerance, value), + |app| validate_f64_range(&app.draft.drawing_hit_test_tolerance, 1.0, 20.0), + ) + .entry_row_validated( + "Hit-test threshold", + |app| app.draft.drawing_hit_test_linear_threshold.clone(), + |value| Message::TextChanged(TextField::DrawingHitTestThreshold, value), + |app| validate_usize_min(&app.draft.drawing_hit_test_linear_threshold, 1), + ) + .switch_row( + "Enable text background", + "", + |app| app.draft.drawing_text_background_enabled, + |value| Message::ToggleChanged(ToggleField::DrawingTextBackground, value), + ) + .switch_row( + "Start shapes filled", + "", + |app| app.draft.drawing_default_fill_enabled, + |value| Message::ToggleChanged(ToggleField::DrawingFillEnabled, value), + ); +} + +// --------------------------------------------------------------------------- +// Drag tool mapping +// --------------------------------------------------------------------------- + +fn build_drag_mapping(page: &mut PageBuilder) { + page.group_in_area("Drag tool mapping", SearchArea::DrawingDragTools); + + let switcher = gtk::Box::builder() + .orientation(gtk::Orientation::Horizontal) + .halign(gtk::Align::Start) + .css_classes(["linked"]) + .build(); + for button in DRAG_BUTTONS { + let toggle = gtk::Button::with_label(button.label()); + { + let sender = page.sender(); + toggle.connect_clicked(move |_| { + sender.input(Message::DrawingDragMappingSectionToggled(button)); + }); + } + switcher.append(&toggle); + page.bind(move |app, _summary| { + let open = app.active_drawing_drag_button == Some(button); + if toggle.has_css_class("suggested-action") != open { + if open { + toggle.add_css_class("suggested-action"); + } else { + toggle.remove_css_class("suggested-action"); + } + } + }); + } + page.custom(&switcher); + + for button in DRAG_BUTTONS { + build_drag_button_section(page, button); + } +} + +fn build_drag_button_section(page: &mut PageBuilder, button: DragMouseButton) { + let section = gtk::Box::builder() + .orientation(gtk::Orientation::Vertical) + .spacing(6) + .margin_top(12) + .build(); + section.append( + >k::Label::builder() + .label(button.label()) + .xalign(0.0) + .css_classes(["heading"]) + .build(), + ); + let list = boxed_list(); + section.append(&list); + page.custom(§ion); + page.bind(move |app, summary| { + set_visible_if_changed(§ion, drag_section_visible(app, summary, button)); + }); + + let tools = DragToolOption::list_for_button(button); + let tool_labels: Vec = tools + .iter() + .map(|option| option.label().to_string()) + .collect(); + let colors = DragColorOption::list(); + let color_labels: Vec = colors + .iter() + .map(|option| option.label().to_string()) + .collect(); + + for field in DRAG_FIELDS { + section_combo_row( + page, + &list, + field.label(), + tools.clone(), + tool_labels.clone(), + move |app| drag_tool_for_field(drag_button_config(app, button), field), + move |option| Message::DrawingMouseDragToolChanged(button, field, option), + ); + section_combo_row( + page, + &list, + &format!("{} color", field.label()), + colors.clone(), + color_labels.clone(), + move |app| drag_color_for_field(drag_button_config(app, button), field), + move |option| Message::DrawingMouseDragColorChanged(button, field, option), + ); + } +} + +/// Mirrors the old view's `visible_drag_mapping_buttons`: a search that +/// matched the drag area on its own opens every button, otherwise only the +/// section the user picked is open. +fn drag_section_visible( + app: &ConfiguratorApp, + summary: &AppSearchSummary, + button: DragMouseButton, +) -> bool { + let matched_by_search = summary.tab(TabId::Drawing).is_some_and(|search| { + !search.show_all() && search.area_matches(SearchArea::DrawingDragTools) + }); + matched_by_search || app.active_drawing_drag_button == Some(button) +} + +fn drag_button_config(app: &ConfiguratorApp, button: DragMouseButton) -> &DragButtonConfig { + match button { + DragMouseButton::Left => &app.draft.drawing_drag_tools.left, + DragMouseButton::Right => &app.draft.drawing_drag_tools.right, + DragMouseButton::Middle => &app.draft.drawing_drag_tools.middle, + } +} + +fn drag_tool_for_field(config: &DragButtonConfig, field: DragToolField) -> DragToolOption { + match field { + DragToolField::Drag => DragToolOption::from_drag_tool(config.drag_tool), + DragToolField::ShiftDrag => DragToolOption::from_drag_tool(config.shift_drag_tool), + DragToolField::CtrlDrag => DragToolOption::from_drag_tool(config.ctrl_drag_tool), + DragToolField::CtrlShiftDrag => DragToolOption::from_drag_tool(config.ctrl_shift_drag_tool), + DragToolField::TabDrag => DragToolOption::from_drag_tool(config.tab_drag_tool), + } +} + +fn drag_color_for_field(config: &DragButtonConfig, field: DragToolField) -> DragColorOption { + match field { + DragToolField::Drag => DragColorOption::from_color(config.drag_color.as_ref()), + DragToolField::ShiftDrag => DragColorOption::from_color(config.shift_drag_color.as_ref()), + DragToolField::CtrlDrag => DragColorOption::from_color(config.ctrl_drag_color.as_ref()), + DragToolField::CtrlShiftDrag => { + DragColorOption::from_color(config.ctrl_shift_drag_color.as_ref()) + } + DragToolField::TabDrag => DragColorOption::from_color(config.tab_drag_color.as_ref()), + } +} + +// --------------------------------------------------------------------------- +// Font +// --------------------------------------------------------------------------- + +fn build_font(page: &mut PageBuilder) { + page.group_in_area("Font", SearchArea::DrawingFont) + .entry_row( + "Font family", + |app| app.draft.drawing_font_family.clone(), + |value| Message::TextChanged(TextField::DrawingFontFamily, value), + ) + .combo_row( + "Font weight", + "", + FontWeightOption::list(), + FontWeightOption::list() + .iter() + .map(|option| option.label().to_string()) + .collect(), + |app| app.draft.drawing_font_weight_option, + Message::FontWeightOptionSelected, + ) + .entry_row( + "Custom or numeric weight", + |app| app.draft.drawing_font_weight.clone(), + |value| Message::TextChanged(TextField::DrawingFontWeight, value), + ) + .combo_row( + "Font style", + "", + FontStyleOption::list(), + FontStyleOption::list() + .iter() + .map(|option| option.label().to_string()) + .collect(), + |app| app.draft.drawing_font_style_option, + Message::FontStyleOptionSelected, + ); + + let custom_style = conditional_section(page, |app| { + app.draft.drawing_font_style_option == FontStyleOption::Custom + }); + section_entry_row( + page, + &custom_style, + "Custom style", + |app| app.draft.drawing_font_style.clone(), + |value| Message::TextChanged(TextField::DrawingFontStyle, value), + |_app| None, + ); +} + +// --------------------------------------------------------------------------- +// Sections and rows +// --------------------------------------------------------------------------- + +/// A boxed list added to the current group, shown only while the model +/// condition holds. +/// +/// Search visibility stays with the group, which hides the section along with +/// everything else it owns, so this binding answers one question only. +fn conditional_section( + page: &mut PageBuilder, + visible: impl Fn(&ConfiguratorApp) -> bool + 'static, +) -> gtk::ListBox { + let list = boxed_list(); + list.set_margin_top(6); + page.custom(&list); + let handle = list.clone(); + page.bind(move |app, _summary| set_visible_if_changed(&handle, visible(app))); + list +} + +fn section_combo_row( + page: &mut PageBuilder, + list: >k::ListBox, + title: &str, + values: Vec, + labels: Vec, + get: impl Fn(&ConfiguratorApp) -> O + 'static, + to_message: impl Fn(O) -> Message + 'static, +) where + O: Copy + PartialEq + 'static, +{ + let row = combo_row_widget(title, &labels); + let handler = connect_combo(&row, page.sender(), values.clone(), to_message); + list.append(&row); + page.bind(move |app, _summary| { + let current = get(app); + let Some(index) = values.iter().position(|value| *value == current) else { + return; + }; + let index = index as u32; + if row.selected() != index { + // Blocked: the model chose this, so reporting it back as a user + // pick only clears the status line. + row.block_signal(&handler); + row.set_selected(index); + row.unblock_signal(&handler); + } + }); +} + +fn section_entry_row( + page: &mut PageBuilder, + list: >k::ListBox, + title: &str, + get: impl Fn(&ConfiguratorApp) -> String + 'static, + to_message: impl Fn(String) -> Message + 'static, + validate: impl Fn(&ConfiguratorApp) -> Option + 'static, +) { + let row = adw::EntryRow::builder().title(title).build(); + let handler = { + let sender = page.sender(); + row.connect_changed(move |row| sender.input(to_message(row.text().to_string()))) + }; + list.append(&row); + page.bind(move |app, _summary| { + set_text_blocked(&row, &handler, &get(app)); + set_error_if_changed(&row, validate(app)); + }); +} + +fn boxed_list() -> gtk::ListBox { + gtk::ListBox::builder() + .selection_mode(gtk::SelectionMode::None) + .css_classes(["boxed-list"]) + .build() +} + +fn combo_row_widget(title: &str, labels: &[String]) -> adw::ComboRow { + let label_refs: Vec<&str> = labels.iter().map(String::as_str).collect(); + adw::ComboRow::builder() + .title(title) + .model(>k::StringList::new(&label_refs)) + .build() +} + +fn connect_combo( + row: &adw::ComboRow, + sender: ComponentSender, + values: Vec, + to_message: impl Fn(O) -> Message + 'static, +) -> glib::SignalHandlerId { + row.connect_selected_notify(move |row| { + if let Some(value) = values.get(row.selected() as usize) { + sender.input(to_message(*value)); + } + }) +} + +fn icon_button(icon: &str, tooltip: &str) -> gtk::Button { + gtk::Button::builder() + .icon_name(icon) + .tooltip_text(tooltip) + .valign(gtk::Align::Center) + .css_classes(["flat"]) + .build() +} + +fn color_dialog_button() -> gtk::ColorDialogButton { + let button = + gtk::ColorDialogButton::new(Some(gtk::ColorDialog::builder().with_alpha(true).build())); + button.set_valign(gtk::Align::Center); + button +} + +// --------------------------------------------------------------------------- +// Echo-guarded widget writes +// --------------------------------------------------------------------------- + +fn set_visible_if_changed(widget: &impl IsA, visible: bool) { + if widget.is_visible() != visible { + widget.set_visible(visible); + } +} + +fn select_if_changed(row: &adw::ComboRow, values: &[O], current: O) { + if let Some(index) = values.iter().position(|value| *value == current) { + let index = index as u32; + if row.selected() != index { + row.set_selected(index); + } + } +} + +fn set_error_if_changed(row: &adw::EntryRow, error: Option) { + let has_error_class = row.has_css_class("error"); + match error { + Some(message) => { + if !has_error_class { + row.add_css_class("error"); + } + if row.tooltip_text().as_deref() != Some(message.as_str()) { + row.set_tooltip_text(Some(&message)); + } + } + None => { + if has_error_class { + row.remove_css_class("error"); + row.set_tooltip_text(None); + } + } + } +} + +// --------------------------------------------------------------------------- +// Validation +// --------------------------------------------------------------------------- + +/// Error text for a decimal field constrained to `min..=max`, `None` while +/// the input is acceptable. Ported from the Iced view's shared validators so +/// these fields keep the feedback they had. +fn validate_f64_range(value: &str, min: f64, max: f64) -> Option { + let trimmed = value.trim(); + if trimmed.is_empty() { + return Some("Expected a numeric value".to_string()); + } + match trimmed.parse::() { + Ok(parsed) if (min..=max).contains(&parsed) => None, + Ok(_) => Some(format!( + "Range: {}-{}", + format_float(min), + format_float(max) + )), + Err(_) => Some("Expected a numeric value".to_string()), + } +} + +/// Error text for a whole-number field constrained to `min..=max`. +fn validate_usize_range(value: &str, min: usize, max: usize) -> Option { + let trimmed = value.trim(); + if trimmed.is_empty() { + return Some("Expected a whole number".to_string()); + } + match trimmed.parse::() { + Ok(parsed) if (min..=max).contains(&parsed) => None, + Ok(_) => Some(format!("Range: {min}-{max}")), + Err(_) => Some("Expected a whole number".to_string()), + } +} + +/// Error text for a whole-number field with a lower bound only. +fn validate_usize_min(value: &str, min: usize) -> Option { + let trimmed = value.trim(); + if trimmed.is_empty() { + return Some("Expected a whole number".to_string()); + } + match trimmed.parse::() { + Ok(parsed) if parsed >= min => None, + Ok(_) => Some(format!("Minimum: {min}")), + Err(_) => Some("Expected a whole number".to_string()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::models::SearchQuery; + + /// The law the binding rests on: one layout per quick color, so the rows + /// it builds and the values it hands them are indexed by the same thing. + #[test] + fn there_is_one_layout_per_quick_color() { + let (app, _effects) = ConfiguratorApp::new_app(); + let layouts = quick_color_layouts(&app); + + assert_eq!(layouts.len(), app.draft.drawing_quick_colors.entries.len()); + } + + /// The caret guarantee, stated as the layout law it rests on: typing a + /// color name moves the named-color choice with it, so neither may be + /// part of what rebuilds the row. + #[test] + fn typing_a_quick_color_name_leaves_the_layout_alone() { + let (mut app, _effects) = ConfiguratorApp::new_app(); + let before = quick_color_layouts(&app); + + app.draft + .set_text(TextField::QuickColorName(0), "gree".to_string()); + + assert_eq!(before, quick_color_layouts(&app)); + // The choice the model moved rides the row's values instead, where a + // blocked write can put it in the combo without a rebuild. + assert_eq!( + app.draft.drawing_quick_colors.entries[0] + .color + .selected_named, + NamedColorOption::Custom + ); + } + + #[test] + fn switching_a_quick_color_to_rgb_changes_the_layout() { + let (mut app, _effects) = ConfiguratorApp::new_app(); + let before = quick_color_layouts(&app); + + let Some(entry) = app.draft.drawing_quick_colors.get_mut(0) else { + return; + }; + entry.color.mode = ColorMode::Rgb; + + assert_ne!(before, quick_color_layouts(&app)); + } + + #[test] + fn a_quick_color_name_that_resolves_to_nothing_is_an_error() { + let values = QuickColorValues { + label: "Red", + name: "red", + hex: "#FF0000", + named: NamedColorOption::Red, + preview: Some((1.0, 0.0, 0.0, 1.0)), + summary: "Red".to_string(), + }; + assert_eq!(name_error(&values), None); + + let unresolved = QuickColorValues { + preview: None, + name: "nope", + summary: values.summary.clone(), + ..values + }; + assert!(name_error(&unresolved).is_some()); + + let empty = QuickColorValues { + preview: None, + name: " ", + summary: values.summary.clone(), + ..values + }; + assert_eq!(name_error(&empty), None); + } + + #[test] + fn drag_sections_all_open_when_search_matches_the_drag_area() { + let (mut app, _effects) = ConfiguratorApp::new_app(); + app.search_query = SearchQuery::new("shift"); + let summary = app.search_summary(); + + assert_eq!(app.active_drawing_drag_button, None); + for button in DRAG_BUTTONS { + assert!(drag_section_visible(&app, &summary, button)); + } + } + + #[test] + fn drag_sections_follow_the_open_button_without_a_search() { + let (mut app, _effects) = ConfiguratorApp::new_app(); + app.active_drawing_drag_button = Some(DragMouseButton::Right); + let summary = app.search_summary(); + + assert!(drag_section_visible(&app, &summary, DragMouseButton::Right)); + assert!(!drag_section_visible(&app, &summary, DragMouseButton::Left)); + } + + #[test] + fn range_validators_report_the_old_view_messages() { + assert_eq!(validate_f64_range("25", 1.0, 50.0), None); + assert_eq!( + validate_f64_range("60", 1.0, 50.0), + Some("Range: 1-50".to_string()) + ); + assert_eq!( + validate_usize_range("2", 3, 12), + Some("Range: 3-12".to_string()) + ); + assert_eq!(validate_usize_min("0", 1), Some("Minimum: 1".to_string())); + } +} diff --git a/configurator/src/app/pages/history.rs b/configurator/src/app/pages/history.rs new file mode 100644 index 00000000..4ca1bd48 --- /dev/null +++ b/configurator/src/app/pages/history.rs @@ -0,0 +1,185 @@ +//! History page: undo/redo delays and the optional custom section. +//! +//! The custom section is one `AdwExpanderRow`: its enable switch sends the +//! same `HistoryCustomSectionEnabled` toggle the Iced checkbox sent, and the +//! four fields it controls are its child rows, so turning the section off +//! folds them away instead of leaving them greyed out. Keeping switch and +//! fields in one row also keeps the group free for the search filter its +//! area owns — a second visibility binding on the group would overwrite it. + +use relm4::adw; +use relm4::prelude::*; + +use adw::prelude::*; + +use crate::messages::Message; +use crate::models::{TabId, TextField, ToggleField}; + +use super::super::search::SearchArea; +use super::super::state::ConfiguratorApp; +use super::{BuiltPage, PageBuilder, set_text_blocked, validate_u32_range}; + +/// Delay bounds, in milliseconds, shared by every delay field here. +const DELAY_RANGE: (u32, u32) = (50, 5000); +/// Step-count bounds for the custom section's undo/redo counts. +const STEPS_RANGE: (u32, u32) = (1, 500); + +pub(super) fn build(sender: &ComponentSender) -> BuiltPage { + let mut page = PageBuilder::new(sender, TabId::History); + + page.group_in_area("Delays", SearchArea::HistoryMain) + .entry_row_validated( + "Undo all delay (ms)", + |app| app.draft.history_undo_all_delay_ms.clone(), + |value| Message::TextChanged(TextField::HistoryUndoAllDelayMs, value), + |app| { + validate_u32_range( + &app.draft.history_undo_all_delay_ms, + DELAY_RANGE.0, + DELAY_RANGE.1, + ) + }, + ) + .entry_row_validated( + "Redo all delay (ms)", + |app| app.draft.history_redo_all_delay_ms.clone(), + |value| Message::TextChanged(TextField::HistoryRedoAllDelayMs, value), + |app| { + validate_u32_range( + &app.draft.history_redo_all_delay_ms, + DELAY_RANGE.0, + DELAY_RANGE.1, + ) + }, + ); + + page.group_in_area("Custom section", SearchArea::HistoryCustom); + let section = custom_section(&mut page); + custom_field( + &mut page, + §ion, + "Custom undo delay (ms)", + TextField::HistoryCustomUndoDelayMs, + |app| app.draft.history_custom_undo_delay_ms.clone(), + DELAY_RANGE, + ); + custom_field( + &mut page, + §ion, + "Custom redo delay (ms)", + TextField::HistoryCustomRedoDelayMs, + |app| app.draft.history_custom_redo_delay_ms.clone(), + DELAY_RANGE, + ); + custom_field( + &mut page, + §ion, + "Custom undo steps", + TextField::HistoryCustomUndoSteps, + |app| app.draft.history_custom_undo_steps.clone(), + STEPS_RANGE, + ); + custom_field( + &mut page, + §ion, + "Custom redo steps", + TextField::HistoryCustomRedoSteps, + |app| app.draft.history_custom_redo_steps.clone(), + STEPS_RANGE, + ); + + page.finish() +} + +/// The expander whose enable switch is the custom-section toggle. +fn custom_section(page: &mut PageBuilder) -> adw::ExpanderRow { + // Starts folded, the shipped default: a switch that opens agreeing with + // the draft keeps the first refresh from echoing a toggle back. + let row = adw::ExpanderRow::builder() + .title("Enable custom undo/redo section") + .show_enable_switch(true) + .enable_expansion(false) + .build(); + let handler = { + let sender = page.sender(); + row.connect_enable_expansion_notify(move |row| { + sender.input(Message::ToggleChanged( + ToggleField::HistoryCustomSectionEnabled, + row.enables_expansion(), + )); + }) + }; + page.custom(&row); + { + let row = row.clone(); + page.bind(move |app, _summary| { + let enabled = app.draft.history_custom_section_enabled; + if row.enables_expansion() != enabled { + // Blocked: the draft decided this, and reporting it back as a + // user toggle resets the status line — which after a load is + // carrying that load's diagnostics. + row.block_signal(&handler); + row.set_enable_expansion(enabled); + row.unblock_signal(&handler); + } + }); + } + row +} + +/// One field of the custom section, nested in `section` rather than in the +/// group, so it follows the enable switch. +/// +/// The Iced view stopped validating these while the section was off: a +/// disabled section's leftover text is not an error the user can act on. +fn custom_field( + page: &mut PageBuilder, + section: &adw::ExpanderRow, + title: &str, + field: TextField, + get: impl Fn(&ConfiguratorApp) -> String + 'static, + range: (u32, u32), +) { + let row = adw::EntryRow::builder().title(title).build(); + let handler = { + let sender = page.sender(); + row.connect_changed(move |row| { + sender.input(Message::TextChanged(field, row.text().to_string())); + }) + }; + section.add_row(&row); + page.bind(move |app, _summary| { + let value = get(app); + // Blocked: the model owns this text, and a load reporting its own + // value back as a user edit clears that load's diagnostics. + set_text_blocked(&row, &handler, &value); + let error = app + .draft + .history_custom_section_enabled + .then(|| validate_u32_range(&value, range.0, range.1)) + .flatten(); + show_error(&row, error); + }); +} + +/// Marks a row `.error` with the reason as its tooltip, the way +/// `PageBuilder::entry_row_validated` marks the rows it owns. +fn show_error(row: &adw::EntryRow, error: Option) { + let has_error_class = row.has_css_class("error"); + match error { + Some(message) => { + if !has_error_class { + row.add_css_class("error"); + } + if row.tooltip_text().as_deref() != Some(message.as_str()) { + row.set_tooltip_text(Some(&message)); + } + } + None => { + if has_error_class { + row.remove_css_class("error"); + row.set_tooltip_text(None); + } + } + } +} diff --git a/configurator/src/app/pages/keybindings.rs b/configurator/src/app/pages/keybindings.rs new file mode 100644 index 00000000..54a56521 --- /dev/null +++ b/configurator/src/app/pages/keybindings.rs @@ -0,0 +1,184 @@ +//! Keybindings page: nine sections of shortcut lists. +//! +//! The section in view is model state, not widget state: the switcher sends +//! `KeybindingsTabSelected` and a binding drives the stack from +//! `active_keybindings_tab`, so a deep link or the search realignment that +//! moves the active section shows up here without anyone touching the stack. + +use relm4::prelude::*; +use relm4::{adw, gtk}; + +use adw::prelude::*; + +use crate::messages::Message; +use crate::models::{KeybindingField, KeybindingsTabId, TabId}; + +use super::super::search::AppSearchSummary; +use super::super::state::ConfiguratorApp; +use super::{Binding, BuiltPage, set_text_blocked}; + +/// The format hint the Iced page carried in its heading. +const SECTION_DESCRIPTION: &str = "Shortcut lists, separated by commas."; + +pub(super) fn build(sender: &ComponentSender) -> BuiltPage { + let stack = gtk::Stack::builder() + .transition_type(gtk::StackTransitionType::Crossfade) + .vexpand(true) + .build(); + let mut bindings: Vec = Vec::new(); + let mut sections: Vec<(KeybindingsTabId, adw::PreferencesPage)> = Vec::new(); + let fields = KeybindingField::all(); + + for tab in KeybindingsTabId::ALL { + let section = adw::PreferencesPage::new(); + let group = adw::PreferencesGroup::builder() + .description(SECTION_DESCRIPTION) + .build(); + section.add(&group); + for field in fields.iter().copied().filter(|field| field.tab() == tab) { + binding_row(&group, field, sender, &mut bindings); + } + stack.add_titled(§ion, Some(tab.title()), tab.title()); + sections.push((tab, section)); + } + + { + let sender = sender.clone(); + stack.connect_visible_child_name_notify(move |stack| { + let Some(name) = stack.visible_child_name() else { + return; + }; + let Some(tab) = tab_from_name(&name) else { + return; + }; + sender.input(Message::KeybindingsTabSelected(tab)); + }); + } + + { + let stack = stack.clone(); + bindings.push(Box::new(move |app, summary| { + // Reveal before switching and hide after: the stack drops a + // visible child that goes invisible, and picking the section the + // model asks for first keeps that fallback out of the way. + for (tab, section) in §ions { + if section_visible(summary, *tab) && !section.is_visible() { + section.set_visible(true); + } + } + let name = app.active_keybindings_tab.title(); + if stack.visible_child_name().as_deref() != Some(name) { + stack.set_visible_child_name(name); + } + for (tab, section) in §ions { + if !section_visible(summary, *tab) && section.is_visible() { + section.set_visible(false); + } + } + })); + } + + let switcher = gtk::StackSwitcher::builder() + .stack(&stack) + .halign(gtk::Align::Center) + .margin_top(12) + .margin_start(12) + .margin_end(12) + .build(); + // Nine section buttons are wider than a narrow window; let them scroll + // sideways rather than set a floor on the window's width. + let switcher_scroll = gtk::ScrolledWindow::builder() + .hscrollbar_policy(gtk::PolicyType::Automatic) + .vscrollbar_policy(gtk::PolicyType::Never) + .propagate_natural_height(true) + .child(&switcher) + .build(); + + let content = gtk::Box::new(gtk::Orientation::Vertical, 0); + content.append(&switcher_scroll); + content.append(&stack); + + BuiltPage { + widget: content.upcast(), + bindings, + } +} + +/// One shortcut field: the draft's comma-joined list, with the default +/// binding alongside it the way the Iced row showed it. +fn binding_row( + group: &adw::PreferencesGroup, + field: KeybindingField, + sender: &ComponentSender, + bindings: &mut Vec, +) { + let row = adw::EntryRow::builder().title(field.label()).build(); + let handler = { + let sender = sender.clone(); + row.connect_changed(move |row| { + sender.input(Message::KeybindingChanged(field, row.text().to_string())); + }) + }; + // Bounded so a two-shortcut default cannot crowd out the entry; the + // whole list stays readable in the tooltip. + let default_label = gtk::Label::builder() + .css_classes(["dim-label", "caption"]) + .valign(gtk::Align::Center) + .ellipsize(gtk::pango::EllipsizeMode::End) + .max_width_chars(28) + .build(); + row.add_suffix(&default_label); + group.add(&row); + + bindings.push(Box::new(move |app, summary| { + let value = app.draft.keybindings.value_for(field).unwrap_or_default(); + // Blocked: the draft owns this list, and a load reporting its own + // value back as a user edit clears that load's diagnostics. + set_text_blocked(&row, &handler, value); + + let default = app + .defaults + .keybindings + .value_for(field) + .unwrap_or_default(); + let default_text = if default.trim().is_empty() { + String::new() + } else { + format!("Default: {default}") + }; + if default_label.label() != default_text { + default_label.set_label(&default_text); + } + let tooltip = (!default_text.is_empty()).then_some(default_text.as_str()); + if default_label.tooltip_text().as_deref() != tooltip { + default_label.set_tooltip_text(tooltip); + } + + let visible = row_visible(summary, field); + if row.is_visible() != visible { + row.set_visible(visible); + } + })); +} + +fn tab_from_name(name: &str) -> Option { + KeybindingsTabId::ALL + .into_iter() + .find(|tab| tab.title() == name) +} + +/// Whether a section still has anything the search asked for, which is also +/// what decides if the switcher offers it. +fn section_visible(summary: &AppSearchSummary, tab: KeybindingsTabId) -> bool { + summary + .tab(TabId::Keybindings) + .is_none_or(|keybindings| keybindings.keybindings_tab_visible(tab)) +} + +/// A field row shows when it matched, or when its section's title did. +fn row_visible(summary: &AppSearchSummary, field: KeybindingField) -> bool { + summary.tab(TabId::Keybindings).is_none_or(|keybindings| { + keybindings.keybinding_field_visible(field) + || keybindings.keybinding_tab_title_visible(field.tab()) + }) +} diff --git a/configurator/src/app/pages/mod.rs b/configurator/src/app/pages/mod.rs new file mode 100644 index 00000000..d5a64234 --- /dev/null +++ b/configurator/src/app/pages/mod.rs @@ -0,0 +1,386 @@ +//! Page construction: one module per sidebar tab. +//! +//! Every page is built once at startup and refreshed through the bindings +//! it registers: a binding reads the model (and the current search summary) +//! and writes the widget only when the value differs, which both avoids +//! redundant layout work and breaks the signal echo a programmatic set +//! would otherwise cause. User edits never write the model directly — each +//! row's signal sends the same [`Message`] the Iced view sent, so the +//! update layer is unchanged. + +mod arrow; +mod boards; +mod capture; +pub(crate) mod color_rows; +mod daemon; +mod drawing; +mod history; +mod keybindings; +mod performance; +mod presets; +mod render_profiles; +mod session; +#[cfg(feature = "tablet-input")] +mod tablet; +mod ui; + +use relm4::prelude::*; +use relm4::{adw, gtk}; + +use adw::prelude::*; +use gtk::glib::SignalHandlerId; + +use crate::messages::Message; +use crate::models::TabId; + +use super::search::{AppSearchSummary, SearchArea}; +use super::state::ConfiguratorApp; + +/// One view refresh: reads the model, writes a widget when it differs. +/// +/// `FnMut`, not `Fn`, because `update_view` holds `&mut AppWidgets` and a +/// binding is therefore free to own state that has to survive between +/// refreshes. That is what lets a page whose rows are a user-sized list keep +/// the layout it last built and the typed refresh closure of every row it +/// built beside it: the two are replaced together, so a row and the values it +/// is handed cannot drift apart. +pub(crate) type Binding = Box; + +/// Writes editable text the model owns without reporting it as a user edit. +/// +/// Every refresh that writes a control the user can also drive goes through a +/// helper like this one: without the block a programmatic write reports itself +/// as a user edit, which re-runs whatever the setter derives and clears the +/// status line the user is reading. +pub(crate) fn set_text_blocked( + editable: &impl IsA, + handler: &SignalHandlerId, + value: &str, +) { + if editable.text() == value { + return; + } + editable.block_signal(handler); + editable.set_text(value); + editable.unblock_signal(handler); +} + +/// Moves a combo to the entry the model chose without reporting it as a pick. +pub(crate) fn set_selected_blocked(row: &adw::ComboRow, handler: &SignalHandlerId, index: u32) { + if row.selected() == index { + return; + } + row.block_signal(handler); + row.set_selected(index); + row.unblock_signal(handler); +} + +pub(crate) struct BuiltPage { + pub(crate) widget: gtk::Widget, + pub(crate) bindings: Vec, +} + +/// Stable `GtkStack` child name for a tab (deep links and view sync key off +/// the model's `active_tab`, never off these strings). +pub(crate) fn stack_name(tab: TabId) -> &'static str { + tab.title() +} + +/// Builds every page in sidebar order. +pub(crate) fn build_all(sender: &ComponentSender) -> Vec<(TabId, BuiltPage)> { + TabId::ALL + .into_iter() + .map(|tab| { + let page = match tab { + TabId::Daemon => daemon::build(sender), + TabId::Drawing => drawing::build(sender), + TabId::Presets => presets::build(sender), + TabId::Ui => ui::build(sender), + TabId::Boards => boards::build(sender), + TabId::RenderProfiles => render_profiles::build(sender), + TabId::Performance => performance::build(sender), + TabId::History => history::build(sender), + TabId::Capture => capture::build(sender), + TabId::Session => session::build(sender), + TabId::Keybindings => keybindings::build(sender), + TabId::Arrow => arrow::build(sender), + #[cfg(feature = "tablet-input")] + TabId::Tablet => tablet::build(sender), + }; + (tab, page) + }) + .collect() +} + +/// Error text for a whole-number field constrained to `min..=max`, `None` +/// while the input is acceptable. +pub(crate) fn validate_u32_range(value: &str, min: u32, max: u32) -> Option { + match value.trim().parse::() { + Ok(parsed) if (min..=max).contains(&parsed) => None, + _ => Some(format!("Enter a whole number between {min} and {max}.")), + } +} + +/// Incremental builder for a preferences-style page: groups of Adwaita rows +/// wired to messages, with per-group search visibility. +pub(crate) struct PageBuilder { + sender: ComponentSender, + tab: TabId, + page: adw::PreferencesPage, + group: adw::PreferencesGroup, + bindings: Vec, +} + +impl PageBuilder { + pub(crate) fn new(sender: &ComponentSender, tab: TabId) -> Self { + let page = adw::PreferencesPage::new(); + let group = adw::PreferencesGroup::new(); + page.add(&group); + Self { + sender: sender.clone(), + tab, + page, + group, + bindings: Vec::new(), + } + } + + /// Starts a new titled group of rows. + pub(crate) fn group(&mut self, title: &str) -> &mut Self { + let group = adw::PreferencesGroup::builder().title(title).build(); + self.page.add(&group); + self.group = group; + self + } + + /// Starts a new titled group whose visibility follows a search area: + /// with an active search the group shows only when the area matched, + /// mirroring the Iced views' section filtering. + pub(crate) fn group_in_area(&mut self, title: &str, area: SearchArea) -> &mut Self { + self.group(title); + let group = self.group.clone(); + let tab = self.tab; + self.bindings.push(Box::new(move |_app, summary| { + let visible = !summary.is_active() + || summary + .tab(tab) + .is_some_and(|tab_summary| tab_summary.area_matches(area)); + if group.is_visible() != visible { + group.set_visible(visible); + } + })); + self + } + + /// Starts a new titled group visible only while its search area matches + /// AND a model condition holds — one binding, so the two gates cannot + /// overwrite each other the way stacked visibility bindings would. + pub(crate) fn group_in_area_when( + &mut self, + title: &str, + area: SearchArea, + condition: impl Fn(&ConfiguratorApp) -> bool + 'static, + ) -> &mut Self { + self.group(title); + let group = self.group.clone(); + let tab = self.tab; + self.bindings.push(Box::new(move |app, summary| { + let area_visible = !summary.is_active() + || summary + .tab(tab) + .is_some_and(|tab_summary| tab_summary.area_matches(area)); + let visible = area_visible && condition(app); + if group.is_visible() != visible { + group.set_visible(visible); + } + })); + self + } + + /// Binds the current group's visibility to an arbitrary model condition + /// (collapsed sections, feature-dependent rows). + pub(crate) fn group_visible_when( + &mut self, + visible: impl Fn(&ConfiguratorApp) -> bool + 'static, + ) -> &mut Self { + let group = self.group.clone(); + self.bindings.push(Box::new(move |app, _summary| { + let value = visible(app); + if group.is_visible() != value { + group.set_visible(value); + } + })); + self + } + + /// A boolean row: `AdwSwitchRow` sending `to_message(state)` on change. + pub(crate) fn switch_row( + &mut self, + title: &str, + subtitle: &str, + get: impl Fn(&ConfiguratorApp) -> bool + 'static, + to_message: impl Fn(bool) -> Message + 'static, + ) -> &mut Self { + let row = adw::SwitchRow::builder().title(title).build(); + if !subtitle.is_empty() { + row.set_subtitle(subtitle); + } + let handler = { + let sender = self.sender.clone(); + row.connect_active_notify(move |row| { + sender.input(to_message(row.is_active())); + }) + }; + self.group.add(&row); + self.bindings.push(Box::new(move |app, _summary| { + let value = get(app); + if row.is_active() != value { + // Blocked: a refresh that reported itself would be indistinct + // from a user toggle, and not every toggle message is a plain + // setter — the toolbar one pins an explicit visibility entry, + // so an echo would dirty a config nobody edited. + row.block_signal(&handler); + row.set_active(value); + row.unblock_signal(&handler); + } + })); + self + } + + /// A free-text row: `AdwEntryRow` sending `to_message(text)` on change. + pub(crate) fn entry_row( + &mut self, + title: &str, + get: impl Fn(&ConfiguratorApp) -> String + 'static, + to_message: impl Fn(String) -> Message + 'static, + ) -> &mut Self { + self.entry_row_validated(title, get, to_message, |_| None) + } + + /// A free-text row with live validation: a non-`None` result marks the + /// row `.error` and shows the text as its tooltip. + pub(crate) fn entry_row_validated( + &mut self, + title: &str, + get: impl Fn(&ConfiguratorApp) -> String + 'static, + to_message: impl Fn(String) -> Message + 'static, + validate: impl Fn(&ConfiguratorApp) -> Option + 'static, + ) -> &mut Self { + let row = adw::EntryRow::builder().title(title).build(); + let handler = { + let sender = self.sender.clone(); + row.connect_changed(move |row| { + sender.input(to_message(row.text().to_string())); + }) + }; + self.group.add(&row); + self.bindings.push(Box::new(move |app, _summary| { + let value = get(app); + if row.text() != value { + // Blocked: the model owns this text, and reporting its own + // value back as a user edit clears the status line the user + // is reading and re-runs whatever the setter derives. + row.block_signal(&handler); + row.set_text(&value); + row.unblock_signal(&handler); + } + let error = validate(app); + let has_error_class = row.has_css_class("error"); + match error { + Some(message) => { + if !has_error_class { + row.add_css_class("error"); + } + if row.tooltip_text().as_deref() != Some(message.as_str()) { + row.set_tooltip_text(Some(&message)); + } + } + None => { + if has_error_class { + row.remove_css_class("error"); + row.set_tooltip_text(None); + } + } + } + })); + self + } + + /// A single-choice row: `AdwComboRow` over `values`, labeled by + /// `labels`, sending `to_message(value)` on selection. + pub(crate) fn combo_row( + &mut self, + title: &str, + subtitle: &str, + values: Vec, + labels: Vec, + get: impl Fn(&ConfiguratorApp) -> O + 'static, + to_message: impl Fn(O) -> Message + 'static, + ) -> &mut Self + where + O: Copy + PartialEq + 'static, + { + let label_refs: Vec<&str> = labels.iter().map(String::as_str).collect(); + let row = adw::ComboRow::builder() + .title(title) + .model(>k::StringList::new(&label_refs)) + .build(); + if !subtitle.is_empty() { + row.set_subtitle(subtitle); + } + let handler = { + let sender = self.sender.clone(); + let values = values.clone(); + row.connect_selected_notify(move |row| { + if let Some(value) = values.get(row.selected() as usize) { + sender.input(to_message(*value)); + } + }) + }; + self.group.add(&row); + self.bindings.push(Box::new(move |app, _summary| { + let current = get(app); + if let Some(index) = values.iter().position(|value| *value == current) { + let index = index as u32; + if row.selected() != index { + // Blocked: the model chose this, so reporting it back as + // a user pick only clears the status line. + row.block_signal(&handler); + row.set_selected(index); + row.unblock_signal(&handler); + } + } + })); + self + } + + /// Adds a fully custom row or widget to the current group. + pub(crate) fn custom(&mut self, widget: &impl IsA) -> &mut Self { + self.group.add(widget); + self + } + + /// Registers an arbitrary refresh binding. + /// + /// `FnMut`, so a binding may own the state its section needs between + /// refreshes — the dynamic lists keep their built rows this way. + pub(crate) fn bind( + &mut self, + binding: impl FnMut(&ConfiguratorApp, &AppSearchSummary) + 'static, + ) -> &mut Self { + self.bindings.push(Box::new(binding)); + self + } + + /// A clone of the sender for wiring custom widgets. + pub(crate) fn sender(&self) -> ComponentSender { + self.sender.clone() + } + + pub(crate) fn finish(self) -> BuiltPage { + BuiltPage { + widget: self.page.upcast(), + bindings: self.bindings, + } + } +} diff --git a/configurator/src/app/pages/performance.rs b/configurator/src/app/pages/performance.rs new file mode 100644 index 00000000..98764e77 --- /dev/null +++ b/configurator/src/app/pages/performance.rs @@ -0,0 +1,74 @@ +//! Performance page: rendering and animation settings. +//! +//! The exemplar for page ports: every control is one `PageBuilder` row +//! sending the same message the Iced view sent, groups map to the same +//! `SearchArea`s, and metadata-driven labels/help come from the shared +//! `performance_field_metadata` table. + +use relm4::prelude::*; +use wayscriber::config::{PerformanceFieldId, performance_field_metadata}; + +use crate::messages::Message; +use crate::models::{TabId, TextField, ToggleField}; + +use super::super::search::SearchArea; +use super::super::state::ConfiguratorApp; +use super::{BuiltPage, PageBuilder, validate_u32_range}; + +pub(super) fn build(sender: &ComponentSender) -> BuiltPage { + let buffer_metadata = performance_field_metadata(PerformanceFieldId::BufferCount); + let vsync_metadata = performance_field_metadata(PerformanceFieldId::EnableVsync); + let max_fps_metadata = performance_field_metadata(PerformanceFieldId::MaxFpsNoVsync); + let animation_metadata = performance_field_metadata(PerformanceFieldId::UiAnimationFps); + + let buffer_counts: Vec = buffer_metadata + .constraint + .unsigned_choices() + .into_iter() + .flatten() + .copied() + .collect(); + let buffer_labels: Vec = buffer_counts.iter().map(u32::to_string).collect(); + let max_fps_range = max_fps_metadata.constraint.unsigned_range(); + let animation_range = animation_metadata.constraint.unsigned_range(); + + let mut page = PageBuilder::new(sender, TabId::Performance); + + page.group_in_area("Rendering", SearchArea::PerformanceRendering) + .combo_row( + buffer_metadata.label, + buffer_metadata.help, + buffer_counts, + buffer_labels, + |app| app.draft.performance_buffer_count, + Message::BufferCountChanged, + ) + .switch_row( + vsync_metadata.label, + vsync_metadata.help, + |app| app.draft.performance_enable_vsync, + |value| Message::ToggleChanged(ToggleField::PerformanceVsync, value), + ) + .entry_row_validated( + max_fps_metadata.label, + |app| app.draft.performance_max_fps_no_vsync.clone(), + |value| Message::TextChanged(TextField::PerformanceMaxFpsNoVsync, value), + move |app| { + let (min, max) = max_fps_range?; + validate_u32_range(&app.draft.performance_max_fps_no_vsync, min, max) + }, + ); + + page.group_in_area("Animations", SearchArea::PerformanceAnimations) + .entry_row_validated( + animation_metadata.label, + |app| app.draft.performance_ui_animation_fps.clone(), + |value| Message::TextChanged(TextField::PerformanceUiAnimationFps, value), + move |app| { + let (min, max) = animation_range?; + validate_u32_range(&app.draft.performance_ui_animation_fps, min, max) + }, + ); + + page.finish() +} diff --git a/configurator/src/app/pages/presets.rs b/configurator/src/app/pages/presets.rs new file mode 100644 index 00000000..015b4efa --- /dev/null +++ b/configurator/src/app/pages/presets.rs @@ -0,0 +1,588 @@ +//! Presets page: the tool preset slots the toolbar offers. +//! +//! `PRESET_SLOTS_MAX` is a compile-time constant, so every slot the build +//! supports exists as one `AdwExpanderRow` from startup and the configured +//! count only decides which of them are visible — no row is ever added or +//! removed while the app runs. +//! +//! Each expander carries the whole slot: its enable switch is the slot's +//! `enabled` flag, its expansion mirrors `preset_collapsed`, and every row +//! inside sends the same indexed [`Message`] the Iced slot view sent. + +use relm4::prelude::*; +use relm4::{adw, gtk}; + +use adw::prelude::*; +use wayscriber::config::{PRESET_SLOTS_MAX, PRESET_SLOTS_MIN}; + +use gtk::glib::SignalHandlerId; + +use crate::messages::Message; +use crate::models::{ + ColorMode, NamedColorOption, OverrideOption, PresetEraserKindOption, PresetEraserModeOption, + PresetTextField, PresetToggleField, TabId, ToolOption, +}; + +use super::super::search::{AppSearchSummary, SearchArea}; +use super::super::state::ConfiguratorApp; +use super::{BuiltPage, PageBuilder, set_selected_blocked, set_text_blocked, validate_u32_range}; + +pub(super) fn build(sender: &ComponentSender) -> BuiltPage { + let mut page = PageBuilder::new(sender, TabId::Presets); + + let counts: Vec = (PRESET_SLOTS_MIN..=PRESET_SLOTS_MAX).collect(); + let count_labels: Vec = counts.iter().map(usize::to_string).collect(); + page.group_in_area("Preset Slots", SearchArea::PresetControls) + .combo_row( + "Visible slots", + "How many preset slots the toolbar offers.", + counts, + count_labels, + |app| app.draft.presets.slot_count, + Message::PresetSlotCountChanged, + ); + + page.group("Slots").group_visible_when(|app| { + let search = app.search_summary(); + (1..=PRESET_SLOTS_MAX).any(|slot| slot_visible(app, &search, slot)) + }); + for slot in 1..=PRESET_SLOTS_MAX { + build_slot(&mut page, slot); + } + + page.finish() +} + +/// Whether one slot shows: within the configured count when the page is +/// unfiltered, and otherwise only when the search matched that slot. +fn slot_visible(app: &ConfiguratorApp, search: &AppSearchSummary, slot: usize) -> bool { + let within_count = slot + <= app + .draft + .presets + .slot_count + .clamp(PRESET_SLOTS_MIN, PRESET_SLOTS_MAX); + if !search.is_active() { + return within_count; + } + match search.tab(TabId::Presets) { + Some(summary) if summary.show_all() => within_count, + Some(summary) => summary.preset_slots().contains(&slot), + None => false, + } +} + +fn build_slot(page: &mut PageBuilder, slot: usize) { + let expander = adw::ExpanderRow::builder() + .title(format!("Slot {slot} settings")) + .show_enable_switch(true) + .build(); + + let enable_handler = { + let sender = page.sender(); + expander.connect_enable_expansion_notify(move |row| { + sender.input(Message::PresetSlotEnabledChanged( + slot, + row.enables_expansion(), + )); + }) + }; + let expanded_handler = { + let sender = page.sender(); + expander.connect_expanded_notify(move |row| { + // Switching a slot off collapses its row on the way; only a + // click on an enabled expander is the user toggling collapse. + if row.enables_expansion() { + sender.input(Message::PresetCollapseToggled(slot)); + } + }) + }; + + let reset = slot_button( + page, + "Reset", + "Restore this slot's default settings", + Message::PresetResetSlot(slot), + ); + expander.add_suffix(&reset); + let duplicate = slot_button( + page, + "Duplicate", + "Copy this slot into the next one", + Message::PresetDuplicateSlot(slot), + ); + // The last slot has nowhere to copy to, exactly as the Iced header's + // Duplicate button was left unpressable there. + duplicate.set_sensitive(slot < PRESET_SLOTS_MAX); + expander.add_suffix(&duplicate); + + page.custom(&expander); + { + let expander = expander.clone(); + page.bind(move |app, search| { + let visible = slot_visible(app, search, slot); + if expander.is_visible() != visible { + expander.set_visible(visible); + } + let Some(draft) = app.draft.presets.slot(slot) else { + return; + }; + + let collapsed = app + .preset_collapsed + .get(slot - 1) + .copied() + .unwrap_or_default(); + let expanded = draft.enabled && !collapsed; + // Both handlers blocked around both writes: `PresetCollapseToggled` + // flips rather than sets, so an expansion the model asked for that + // echoed back as a toggle would undo itself, and enabling a slot + // expands it as a side effect. With the writes silent, only a click + // is ever reported. + if expander.enables_expansion() != draft.enabled { + expander.block_signal(&enable_handler); + expander.block_signal(&expanded_handler); + expander.set_enable_expansion(draft.enabled); + expander.unblock_signal(&expanded_handler); + expander.unblock_signal(&enable_handler); + } + if expander.is_expanded() != expanded { + expander.block_signal(&expanded_handler); + expander.set_expanded(expanded); + expander.unblock_signal(&expanded_handler); + } + + let subtitle = if !draft.enabled { + "Slot disabled. Enable to configure.".to_string() + } else if draft.name.trim().is_empty() { + draft.tool.label().to_string() + } else { + format!("{} — {}", draft.name.trim(), draft.tool.label()) + }; + if expander.subtitle().as_str() != subtitle { + expander.set_subtitle(&subtitle); + } + }); + } + + let mut rows = SlotBuilder { + page, + expander, + slot, + }; + + rows.entry_row( + "Label", + move |app| app.draft.presets.slot(slot).map(|draft| draft.name.clone()), + move |slot, value| Message::PresetTextChanged(slot, PresetTextField::Name, value), + ); + + let tools = ToolOption::list(); + let tool_labels = labels_of(&tools, ToolOption::label); + rows.combo_row( + "Tool", + tools, + tool_labels, + move |app| app.draft.presets.slot(slot).map(|draft| draft.tool), + Message::PresetToolChanged, + ); + + build_color_rows(&mut rows, slot); + + rows.entry_row( + "Size (px)", + move |app| app.draft.presets.slot(slot).map(|draft| draft.size.clone()), + move |slot, value| Message::PresetTextChanged(slot, PresetTextField::Size, value), + ); + rows.entry_row( + "Marker opacity (0.05-0.9)", + move |app| { + app.draft + .presets + .slot(slot) + .map(|draft| draft.marker_opacity.clone()) + }, + move |slot, value| Message::PresetTextChanged(slot, PresetTextField::MarkerOpacity, value), + ); + + let kinds = PresetEraserKindOption::list(); + let kind_labels = labels_of(&kinds, PresetEraserKindOption::label); + rows.combo_row( + "Eraser kind", + kinds, + kind_labels, + move |app| app.draft.presets.slot(slot).map(|draft| draft.eraser_kind), + Message::PresetEraserKindChanged, + ); + let modes = PresetEraserModeOption::list(); + let mode_labels = labels_of(&modes, PresetEraserModeOption::label); + rows.combo_row( + "Eraser mode", + modes, + mode_labels, + move |app| app.draft.presets.slot(slot).map(|draft| draft.eraser_mode), + Message::PresetEraserModeChanged, + ); + + rows.override_row("Fill enabled", PresetToggleField::FillEnabled, move |app| { + app.draft.presets.slot(slot).map(|draft| draft.fill_enabled) + }); + rows.override_row( + "Text background", + PresetToggleField::TextBackgroundEnabled, + move |app| { + app.draft + .presets + .slot(slot) + .map(|draft| draft.text_background_enabled) + }, + ); + + rows.entry_row( + "Font size (pt)", + move |app| { + app.draft + .presets + .slot(slot) + .map(|draft| draft.font_size.clone()) + }, + move |slot, value| Message::PresetTextChanged(slot, PresetTextField::FontSize, value), + ); + rows.entry_row( + "Arrow length (px)", + move |app| { + app.draft + .presets + .slot(slot) + .map(|draft| draft.arrow_length.clone()) + }, + move |slot, value| Message::PresetTextChanged(slot, PresetTextField::ArrowLength, value), + ); + rows.entry_row( + "Arrow angle (deg)", + move |app| { + app.draft + .presets + .slot(slot) + .map(|draft| draft.arrow_angle.clone()) + }, + move |slot, value| Message::PresetTextChanged(slot, PresetTextField::ArrowAngle, value), + ); + rows.override_row( + "Arrow head at end", + PresetToggleField::ArrowHeadAtEnd, + move |app| { + app.draft + .presets + .slot(slot) + .map(|draft| draft.arrow_head_at_end) + }, + ); + rows.override_row( + "Show status bar", + PresetToggleField::ShowStatusBar, + move |app| { + app.draft + .presets + .slot(slot) + .map(|draft| draft.show_status_bar) + }, + ); +} + +/// The slot's color block: a mode chooser with a live preview, then the +/// rows the chosen mode edits. +fn build_color_rows(rows: &mut SlotBuilder<'_>, slot: usize) { + let mode_row = rows.combo_row( + "Color mode", + vec![ColorMode::Named, ColorMode::Rgb], + vec!["Named".to_string(), "RGB".to_string()], + move |app| app.draft.presets.slot(slot).map(|draft| draft.color.mode), + Message::PresetColorModeChanged, + ); + let swatch = color_swatch(); + mode_row.add_suffix(&swatch); + // The color the swatch is currently drawing, owned by the binding that + // draws it: a new draw function is what redraws the widget, so it is only + // installed when the color actually moved. + let mut shown: Option<[f64; 3]> = None; + rows.page.bind(move |app, _search| { + let color = app + .draft + .presets + .slot(slot) + .and_then(|draft| draft.color.preview_color()) + .map(|color| [color.r, color.g, color.b]); + if shown == color { + return; + } + shown = color; + match color { + Some(rgb) => swatch.set_draw_func(move |_, context, width, height| { + context.set_source_rgb(rgb[0], rgb[1], rgb[2]); + context.rectangle(0.0, 0.0, f64::from(width), f64::from(height)); + // A failed fill only leaves the swatch blank. + let _ = context.fill(); + }), + // A slot whose color resolves to nothing draws nothing. + None => swatch.set_draw_func(|_, _, _, _| {}), + } + }); + + let named = NamedColorOption::list(); + let named_labels = labels_of(&named, NamedColorOption::label); + let named_row = rows.combo_row( + "Named color", + named, + named_labels, + move |app| { + app.draft + .presets + .slot(slot) + .map(|draft| draft.color.selected_named) + }, + Message::PresetNamedColorSelected, + ); + rows.visible_when(&named_row, move |app| { + app.draft + .presets + .slot(slot) + .is_some_and(|draft| draft.color.mode == ColorMode::Named) + }); + + let custom_name = rows.entry_row_validated( + "Custom color name", + move |app| { + app.draft + .presets + .slot(slot) + .map(|draft| draft.color.name.clone()) + }, + move |slot, value| Message::PresetTextChanged(slot, PresetTextField::ColorName, value), + move |app| { + let color = &app.draft.presets.slot(slot)?.color; + if color.mode != ColorMode::Named { + return None; + } + (color.preview_color().is_none() && !color.name.trim().is_empty()) + .then(|| "Unknown color name.".to_string()) + }, + ); + rows.visible_when(&custom_name, move |app| { + app.draft.presets.slot(slot).is_some_and(|draft| { + draft.color.mode == ColorMode::Named && draft.color.selected_named_is_custom() + }) + }); + + for (component, title) in [ + (0usize, "Red (0-255)"), + (1, "Green (0-255)"), + (2, "Blue (0-255)"), + ] { + let row = rows.entry_row_validated( + title, + move |app| { + app.draft + .presets + .slot(slot) + .and_then(|draft| draft.color.rgb.get(component).cloned()) + }, + move |slot, value| Message::PresetColorComponentChanged(slot, component, value), + move |app| { + let color = &app.draft.presets.slot(slot)?.color; + if color.mode != ColorMode::Rgb { + return None; + } + validate_u32_range(color.rgb.get(component)?, 0, 255) + }, + ); + rows.visible_when(&row, move |app| { + app.draft + .presets + .slot(slot) + .is_some_and(|draft| draft.color.mode == ColorMode::Rgb) + }); + } +} + +/// Row builder for one slot. +/// +/// Mirrors the [`PageBuilder`] row helpers, but adds rows to the slot's +/// expander and reads the slot out of the model, so a slot the draft does +/// not hold simply leaves its rows alone. +struct SlotBuilder<'a> { + page: &'a mut PageBuilder, + expander: adw::ExpanderRow, + slot: usize, +} + +impl SlotBuilder<'_> { + /// A free-text row sending `to_message(slot, text)` on change. + fn entry_row( + &mut self, + title: &str, + get: impl Fn(&ConfiguratorApp) -> Option + 'static, + to_message: impl Fn(usize, String) -> Message + 'static, + ) -> adw::EntryRow { + self.entry_row_validated(title, get, to_message, |_app| None) + } + + /// A free-text row with live validation: a non-`None` result marks the + /// row `.error` and shows the text as its tooltip. + fn entry_row_validated( + &mut self, + title: &str, + get: impl Fn(&ConfiguratorApp) -> Option + 'static, + to_message: impl Fn(usize, String) -> Message + 'static, + validate: impl Fn(&ConfiguratorApp) -> Option + 'static, + ) -> adw::EntryRow { + let row = adw::EntryRow::builder().title(title).build(); + let slot = self.slot; + let handler = { + let sender = self.page.sender(); + row.connect_changed(move |row| { + sender.input(to_message(slot, row.text().to_string())); + }) + }; + self.expander.add_row(&row); + { + let row = row.clone(); + self.page.bind(move |app, _search| { + if let Some(value) = get(app) { + // Blocked: the draft owns this text, and a load reporting + // its own value back as a user edit clears that load's + // diagnostics from the status line. + set_text_blocked(&row, &handler, &value); + } + set_row_error(&row, validate(app)); + }); + } + row + } + + /// A single-choice row sending `to_message(slot, value)` on selection. + fn combo_row( + &mut self, + title: &str, + values: Vec, + labels: Vec, + get: impl Fn(&ConfiguratorApp) -> Option + 'static, + to_message: impl Fn(usize, O) -> Message + 'static, + ) -> adw::ComboRow + where + O: Copy + PartialEq + 'static, + { + let label_refs: Vec<&str> = labels.iter().map(String::as_str).collect(); + let row = adw::ComboRow::builder() + .title(title) + .model(>k::StringList::new(&label_refs)) + .build(); + let slot = self.slot; + let handler: SignalHandlerId = { + let sender = self.page.sender(); + let values = values.clone(); + row.connect_selected_notify(move |row| { + if let Some(value) = values.get(row.selected() as usize) { + sender.input(to_message(slot, *value)); + } + }) + }; + self.expander.add_row(&row); + { + let row = row.clone(); + self.page.bind(move |app, _search| { + let Some(current) = get(app) else { + return; + }; + if let Some(index) = values.iter().position(|value| *value == current) { + // Blocked: the draft chose this, and reporting it back as + // a user pick clears the status line a load just wrote. + set_selected_blocked(&row, &handler, index as u32); + } + }); + } + row + } + + /// A Default/On/Off row for one of the slot's override fields. + fn override_row( + &mut self, + title: &str, + field: PresetToggleField, + get: impl Fn(&ConfiguratorApp) -> Option + 'static, + ) -> adw::ComboRow { + let values = OverrideOption::list(); + let labels = labels_of(&values, OverrideOption::label); + self.combo_row(title, values, labels, get, move |slot, value| { + Message::PresetToggleOptionChanged(slot, field, value) + }) + } + + /// Binds a row's visibility to a model condition. + fn visible_when( + &mut self, + row: &impl IsA, + visible: impl Fn(&ConfiguratorApp) -> bool + 'static, + ) { + let row = row.clone().upcast::(); + self.page.bind(move |app, _search| { + let value = visible(app); + if row.is_visible() != value { + row.set_visible(value); + } + }); + } +} + +/// A flat header button for one of the slot's actions. +fn slot_button(page: &PageBuilder, label: &str, tooltip: &str, message: Message) -> gtk::Button { + let button = gtk::Button::builder() + .label(label) + .tooltip_text(tooltip) + .valign(gtk::Align::Center) + .css_classes(["flat"]) + .build(); + let sender = page.sender(); + button.connect_clicked(move |_| sender.input(message.clone())); + button +} + +/// A read-only preview of the slot's resolved color. +/// +/// Blank until its binding installs a draw function carrying the color, so +/// the widget holds no state the binding has to encode and decode. +fn color_swatch() -> gtk::DrawingArea { + gtk::DrawingArea::builder() + .content_width(24) + .content_height(24) + .valign(gtk::Align::Center) + .css_classes(["card"]) + .build() +} + +fn set_row_error(row: &adw::EntryRow, error: Option) { + let has_error_class = row.has_css_class("error"); + match error { + Some(message) => { + if !has_error_class { + row.add_css_class("error"); + } + if row.tooltip_text().as_deref() != Some(message.as_str()) { + row.set_tooltip_text(Some(&message)); + } + } + None => { + if has_error_class { + row.remove_css_class("error"); + row.set_tooltip_text(None); + } + } + } +} + +fn labels_of(values: &[O], label: impl Fn(&O) -> &'static str) -> Vec { + values + .iter() + .map(|value| label(value).to_string()) + .collect() +} diff --git a/configurator/src/app/pages/render_profiles.rs b/configurator/src/app/pages/render_profiles.rs new file mode 100644 index 00000000..e49898ce --- /dev/null +++ b/configurator/src/app/pages/render_profiles.rs @@ -0,0 +1,752 @@ +//! Render Profiles page: named color mappings and how they are applied. +//! +//! Profiles and their mappings are both user-sized lists, so the sections +//! cannot be a fixed set of [`PageBuilder`] rows. One container holds one +//! `GtkListBox` per profile, and every section is built together with the +//! closure that refreshes it: that closure owns the section's typed widget +//! handles and the signal handler ids guarding each write, so a refresh never +//! rediscovers a control by position and never reports a programmatic write +//! as a user edit. +//! +//! The binding owns both halves of that arrangement: the [`SectionLayout`] +//! list it last built — each profile's mapping count and everything a search +//! hides — and one typed refresh closure per section. When the layouts differ +//! the two are replaced together, so a section and the values handed to it +//! can never drift apart. A rebuilt row gets its layout before its handlers +//! connect, so the first refresh cannot echo the model straight back at +//! itself. +//! +//! Profile ids, names, and mapping hex stay out of the layout on purpose: +//! they change on every keystroke, and rebuilding would take the caret with +//! it. Those values ride [`ProfileValues`] into the sections' own refresh +//! closures, which write them with their handlers blocked — the swatch +//! reports a pick as `ColorPickerHexChanged`, which rewrites the mapping in +//! canonical form, so an echo would dirty a freshly loaded file. + +use relm4::prelude::*; +use relm4::{adw, gtk}; + +use adw::prelude::*; +use gtk::glib::SignalHandlerId; + +use crate::messages::Message; +use crate::models::color::parse_hex; +use crate::models::{ + ColorPickerId, RenderProfileExportOption, RenderProfileMappingSide, + RenderProfileSelectionOption, RenderProfileTextField, TabId, +}; + +use super::super::search::{AppSearchSummary, SearchArea, TabSearchSummary}; +use super::super::state::ConfiguratorApp; +use super::color_rows::{dialog_hex, mark_hex_error, set_swatch_blocked}; +use super::{BuiltPage, PageBuilder, set_text_blocked}; + +/// `GTK_INVALID_LIST_POSITION`: what a `GtkSingleSelection` reads as "nothing +/// is selected", which is how the Iced pick list rendered an unknown id. +const NO_SELECTION: u32 = u32::MAX; + +pub(super) fn build(sender: &ComponentSender) -> BuiltPage { + let mut page = PageBuilder::new(sender, TabId::RenderProfiles); + + let export_options = RenderProfileExportOption::list(); + let export_labels: Vec = export_options + .iter() + .map(|option| option.label().to_string()) + .collect(); + + page.group_in_area("Render Profiles", SearchArea::RenderProfilesGeneral) + .switch_row( + "Preview canvas", + "", + |app| app.draft.render_profiles.apply_to_canvas, + Message::RenderProfileApplyCanvasChanged, + ) + .switch_row( + "Preview UI", + "", + |app| app.draft.render_profiles.apply_to_ui, + Message::RenderProfileApplyUiChanged, + ); + + add_active_row(&mut page); + + page.combo_row( + "Canvas export profile", + "", + export_options, + export_labels, + |app| app.draft.render_profiles.export, + Message::RenderProfileExportChanged, + ); + + add_export_profile_row(&mut page); + add_add_button(&mut page); + add_profile_list(&mut page); + + page.finish() +} + +/// The startup profile: `Off` plus one entry per profile id. +fn add_active_row(page: &mut PageBuilder) { + let row = adw::ComboRow::builder().title("Startup profile").build(); + row.set_model(Some(>k::StringList::new(&[]))); + let handler = { + let sender = page.sender(); + row.connect_selected_notify(move |row| { + let position = row.selected(); + if position == NO_SELECTION { + return; + } + // Position zero is the `Off` option, whose profile id is empty. + let value = if position == 0 { + String::new() + } else { + let Some(item) = selected_string(row) else { + return; + }; + item + }; + sender.input(Message::RenderProfileActiveChanged(value)); + }) + }; + page.custom(&row); + // The combo's own entries, kept by the binding that writes them. + let mut shown: Vec = Vec::new(); + page.bind(move |app, _summary| { + let profiles = &app.draft.render_profiles; + let ids = profiles.profile_ids(); + let options = RenderProfileSelectionOption::list(&ids); + let current = RenderProfileSelectionOption::from_active(&profiles.active, &ids); + let selected = options.iter().position(|option| *option == current); + let entries: Vec = options.iter().map(ToString::to_string).collect(); + sync_combo(&row, &handler, &mut shown, &entries, selected); + }); +} + +/// The named export profile, which the Iced view showed only while the export +/// mode was `Named profile`. +fn add_export_profile_row(page: &mut PageBuilder) { + let row = adw::ComboRow::builder() + .title("Named export profile") + .build(); + row.set_model(Some(>k::StringList::new(&[]))); + // Hidden until the export mode asks for it, so it cannot flash on the + // first frame. + row.set_visible(false); + let handler = { + let sender = page.sender(); + row.connect_selected_notify(move |row| { + let Some(value) = selected_string(row) else { + return; + }; + sender.input(Message::RenderProfileExportProfileChanged(value)); + }) + }; + page.custom(&row); + // The combo's own entries, kept by the binding that writes them. + let mut shown: Vec = Vec::new(); + page.bind(move |app, _summary| { + let profiles = &app.draft.render_profiles; + let ids = profiles.profile_ids(); + let selected = ids.iter().position(|id| *id == profiles.export_profile); + sync_combo(&row, &handler, &mut shown, &ids, selected); + + let visible = profiles.export == RenderProfileExportOption::Profile; + if row.is_visible() != visible { + row.set_visible(visible); + } + }); +} + +fn add_add_button(page: &mut PageBuilder) { + let button = gtk::Button::builder() + .label("Add profile") + .halign(gtk::Align::Start) + .margin_top(12) + .build(); + { + let sender = page.sender(); + button.connect_clicked(move |_| sender.input(Message::RenderProfileAdd)); + } + page.custom(&button); +} + +/// The per-profile sections, in their own untitled group so search can hide +/// them one at a time instead of as a block. +fn add_profile_list(page: &mut PageBuilder) { + page.group(""); + let container = gtk::Box::builder() + .orientation(gtk::Orientation::Vertical) + .spacing(18) + .build(); + page.custom(&container); + + let sender = page.sender(); + // Each built section owns the layout that produced it and its typed refresh. + let mut sections: Vec = Vec::new(); + page.bind(move |app, summary| { + let layouts = section_layouts(app, summary); + if !sections + .iter() + .map(|section| §ion.layout) + .eq(layouts.iter()) + { + sections = rebuild_sections(&container, &layouts, &sender); + } + for ((index, profile), section) in app + .draft + .render_profiles + .profiles + .iter() + .enumerate() + .zip(sections.iter()) + { + let values = ProfileValues { + id: &profile.id, + name: &profile.name, + mappings: profile + .mappings + .iter() + .enumerate() + .map(|(mapping, values)| MappingValues { + from: picker_hex( + app, + ColorPickerId::RenderProfileMappingFrom(index, mapping), + &values.from, + ), + to: picker_hex( + app, + ColorPickerId::RenderProfileMappingTo(index, mapping), + &values.to, + ), + }) + .collect(), + }; + (section.refresh)(&values); + } + }); +} + +// ---- What a rebuild depends on ----------------------------------------- + +/// Everything a profile section shows before any text is written into it: +/// how many mapping rows it has, and which of its rows a search leaves +/// reachable. +/// +/// This is the whole rebuild trigger: the binding compares the list of these +/// it built against the list the model asks for now, so a control added here +/// rebuilds on its own value without anyone having to remember to extend a +/// format string. +#[derive(Debug, Clone, PartialEq, Eq)] +struct SectionLayout { + visible: bool, + /// Id, name, and the add-mapping button: the profile's own controls. + controls: bool, + /// One flag per mapping row, so the count is part of the fingerprint. + mappings: Vec, +} + +fn section_layouts(app: &ConfiguratorApp, summary: &AppSearchSummary) -> Vec { + let tab = summary.tab(TabId::RenderProfiles); + let show_all = tab.is_none_or(TabSearchSummary::show_all); + let matched: &[usize] = tab.map_or(&[], TabSearchSummary::render_profile_indices); + let mapping_matches: &[(usize, usize)] = + tab.map_or(&[], TabSearchSummary::render_profile_mapping_indices); + + app.draft + .render_profiles + .profiles + .iter() + .enumerate() + .map(|(index, profile)| { + let controls = tab.is_none_or(|tab| tab.render_profile_controls_visible(index)); + SectionLayout { + visible: show_all || matched.contains(&index), + controls, + mappings: (0..profile.mappings.len()) + .map(|mapping| controls || mapping_matches.contains(&(index, mapping))) + .collect(), + } + }) + .collect() +} + +/// One profile row's values: everything the layout deliberately left out. +struct ProfileValues<'a> { + id: &'a str, + name: &'a str, + /// One entry per mapping row, in mapping order. + mappings: Vec>, +} + +/// One mapping row's two editing hex fields. +struct MappingValues<'a> { + from: &'a str, + to: &'a str, +} + +// ---- Sections ---------------------------------------------------------- + +/// One section's refresh: built beside its row, so it owns that row's typed +/// widget handles and the signal handler ids guarding each write. +type ProfileRowRefresh = Box)>; + +struct BoundProfileSection { + layout: SectionLayout, + refresh: ProfileRowRefresh, +} + +/// One profile's section: the list box, and the closure that writes the +/// values the layout deliberately left out. +struct ProfileSection { + section: gtk::ListBox, + refresh: ProfileRowRefresh, +} + +fn rebuild_sections( + container: >k::Box, + layouts: &[SectionLayout], + sender: &ComponentSender, +) -> Vec { + // Draining the container, not walking it for a control: the sections that + // replace these carry their own refresh closures. + while let Some(child) = container.first_child() { + container.remove(&child); + } + + let mut sections = Vec::with_capacity(layouts.len()); + for (index, layout) in layouts.iter().enumerate() { + let built = build_section(index, layout, sender); + container.append(&built.section); + sections.push(BoundProfileSection { + layout: layout.clone(), + refresh: built.refresh, + }); + } + sections +} + +fn build_section( + index: usize, + layout: &SectionLayout, + sender: &ComponentSender, +) -> ProfileSection { + let section = gtk::ListBox::builder() + .selection_mode(gtk::SelectionMode::None) + .css_classes(["boxed-list"]) + .visible(layout.visible) + .build(); + + let title = gtk::Label::builder() + .xalign(0.0) + .hexpand(true) + .css_classes(["heading"]) + .build(); + section.append(&build_header_row(index, &title, sender)); + + let id = build_text_row("Profile id", index, RenderProfileTextField::Id, sender); + id.row.set_visible(layout.controls); + section.append(&id.row); + + let name = build_text_row("Display name", index, RenderProfileTextField::Name, sender); + name.row.set_visible(layout.controls); + section.append(&name.row); + + let mut mappings: Vec = Vec::with_capacity(layout.mappings.len()); + for (mapping, visible) in layout.mappings.iter().enumerate() { + let row = build_mapping_row(index, mapping, sender); + row.row.set_visible(*visible); + section.append(&row.row); + mappings.push(row); + } + + let add = build_add_mapping_row(index, sender); + add.set_visible(layout.controls); + section.append(&add); + + let refresh: ProfileRowRefresh = Box::new(move |values| { + let heading = if values.name.trim().is_empty() { + "Profile" + } else { + values.name.trim() + }; + if title.text() != heading { + title.set_text(heading); + } + + set_text_blocked(&id.row, &id.handler, values.id); + set_text_blocked(&name.row, &name.handler, values.name); + + for (row, hex) in mappings.iter().zip(values.mappings.iter()) { + row.from.refresh(hex.from); + row.to.refresh(hex.to); + } + }); + + ProfileSection { section, refresh } +} + +// ---- Header row -------------------------------------------------------- + +fn build_header_row( + index: usize, + title: >k::Label, + sender: &ComponentSender, +) -> gtk::ListBoxRow { + let content = row_content_box(); + content.append(title); + + let duplicate = gtk::Button::builder() + .label("Duplicate") + .valign(gtk::Align::Center) + .build(); + connect_button(&duplicate, Message::RenderProfileDuplicate(index), sender); + content.append(&duplicate); + + let remove = gtk::Button::builder() + .label("Delete") + .valign(gtk::Align::Center) + .css_classes(["destructive-action"]) + .build(); + connect_button(&remove, Message::RenderProfileRemove(index), sender); + content.append(&remove); + + plain_row(&content) +} + +// ---- Mapping row ------------------------------------------------------- + +/// One side of a mapping: the hex field the Iced view had, plus the native +/// color dialog standing in for its popup picker. +struct ColorField { + hex: gtk::Entry, + hex_handler: SignalHandlerId, + swatch: gtk::ColorDialogButton, + swatch_handler: SignalHandlerId, +} + +impl ColorField { + fn refresh(&self, hex: &str) { + set_text_blocked(&self.hex, &self.hex_handler, hex); + // The same predicate the save gate counts with, so a field styled + // clean can never be one the save refuses. + mark_hex_error(&self.hex, hex); + + let Some((rgb, _)) = parse_hex(hex) else { + // Half-typed hex: leave the swatch on the last color that parsed + // rather than flashing it to black on every keystroke. + return; + }; + let rgba = gtk::gdk::RGBA::new(rgb[0] as f32, rgb[1] as f32, rgb[2] as f32, 1.0); + set_swatch_blocked(&self.swatch, &self.swatch_handler, &rgba); + } +} + +struct MappingRow { + row: gtk::ListBoxRow, + from: ColorField, + to: ColorField, +} + +fn build_mapping_row( + index: usize, + mapping: usize, + sender: &ComponentSender, +) -> MappingRow { + let content = row_content_box(); + + content.append(&side_label("From")); + let from = build_color_field(index, mapping, RenderProfileMappingSide::From, sender); + content.append(&from.hex); + content.append(&from.swatch); + + content.append(&side_label("\u{2192}")); + content.append(&side_label("To")); + let to = build_color_field(index, mapping, RenderProfileMappingSide::To, sender); + content.append(&to.hex); + content.append(&to.swatch); + + let remove = gtk::Button::builder() + .label("Remove") + .valign(gtk::Align::Center) + .halign(gtk::Align::End) + .hexpand(true) + .build(); + connect_button( + &remove, + Message::RenderProfileMappingRemove(index, mapping), + sender, + ); + content.append(&remove); + + MappingRow { + row: plain_row(&content), + from, + to, + } +} + +fn build_color_field( + index: usize, + mapping: usize, + side: RenderProfileMappingSide, + sender: &ComponentSender, +) -> ColorField { + let hex = gtk::Entry::builder() + .placeholder_text("#RRGGBB") + .width_chars(9) + .max_width_chars(9) + .build(); + let hex_handler = { + let sender = sender.clone(); + hex.connect_changed(move |entry| { + sender.input(Message::RenderProfileMappingColorChanged( + index, + mapping, + side, + entry.text().to_string(), + )); + }) + }; + + let swatch = + gtk::ColorDialogButton::new(Some(gtk::ColorDialog::builder().with_alpha(false).build())); + swatch.set_valign(gtk::Align::Center); + let id = match side { + RenderProfileMappingSide::From => ColorPickerId::RenderProfileMappingFrom(index, mapping), + RenderProfileMappingSide::To => ColorPickerId::RenderProfileMappingTo(index, mapping), + }; + let swatch_handler = { + let sender = sender.clone(); + swatch.connect_rgba_notify(move |button| { + sender.input(Message::ColorPickerHexChanged( + id, + dialog_hex(&button.rgba()), + )); + }) + }; + + ColorField { + hex, + hex_handler, + swatch, + swatch_handler, + } +} + +// ---- Add-mapping row --------------------------------------------------- + +fn build_add_mapping_row( + index: usize, + sender: &ComponentSender, +) -> gtk::ListBoxRow { + let content = row_content_box(); + let button = gtk::Button::builder() + .label("Add mapping") + .halign(gtk::Align::Start) + .build(); + connect_button(&button, Message::RenderProfileMappingAdd(index), sender); + content.append(&button); + plain_row(&content) +} + +// ---- Small shared plumbing -------------------------------------------- + +/// An entry row whose text the model owns, kept with the handler a refresh +/// has to block before writing it. +struct TextRow { + row: adw::EntryRow, + handler: SignalHandlerId, +} + +fn build_text_row( + title: &str, + index: usize, + field: RenderProfileTextField, + sender: &ComponentSender, +) -> TextRow { + let row = adw::EntryRow::builder().title(title).build(); + let handler = { + let sender = sender.clone(); + row.connect_changed(move |row| { + sender.input(Message::RenderProfileTextChanged( + index, + field, + row.text().to_string(), + )); + }) + }; + TextRow { row, handler } +} + +fn connect_button( + button: >k::Button, + message: Message, + sender: &ComponentSender, +) { + let sender = sender.clone(); + button.connect_clicked(move |_| sender.input(message.clone())); +} + +/// Rewrites a combo's model only when the choices themselves changed, and +/// writes both model and selection with the change handler blocked: replacing +/// a model resets the selection to the first row, which would otherwise be +/// reported as if the user had picked it. +/// +/// `shown` is what the combo currently offers, owned by the binding that +/// calls this — the entries themselves, not a rendering of them. +fn sync_combo( + row: &adw::ComboRow, + handler: &SignalHandlerId, + shown: &mut Vec, + entries: &[String], + selected: Option, +) { + let rebuild = shown.as_slice() != entries; + let target = selected.map_or(NO_SELECTION, |index| index as u32); + if !rebuild && row.selected() == target { + return; + } + + row.block_signal(handler); + if rebuild { + let refs: Vec<&str> = entries.iter().map(String::as_str).collect(); + row.set_model(Some(>k::StringList::new(&refs))); + shown.clear(); + shown.extend_from_slice(entries); + } + if row.selected() != target { + row.set_selected(target); + } + row.unblock_signal(handler); +} + +fn selected_string(row: &adw::ComboRow) -> Option { + let item = row + .selected_item() + .and_then(|item| item.downcast::().ok())?; + Some(item.string().to_string()) +} + +fn row_content_box() -> gtk::Box { + gtk::Box::builder() + .orientation(gtk::Orientation::Horizontal) + .spacing(6) + .margin_top(8) + .margin_bottom(8) + .margin_start(12) + .margin_end(12) + .build() +} + +fn plain_row(content: &impl IsA) -> gtk::ListBoxRow { + gtk::ListBoxRow::builder() + .child(content) + .activatable(false) + .selectable(false) + .build() +} + +fn side_label(text: &str) -> gtk::Label { + gtk::Label::builder() + .label(text) + .valign(gtk::Align::Center) + .css_classes(["dim-label", "caption"]) + .build() +} + +/// The picker's editing text, falling back to the stored mapping value the +/// way the Iced hex field did. +fn picker_hex<'a>(app: &'a ConfiguratorApp, id: ColorPickerId, value: &'a str) -> &'a str { + app.color_picker_hex.get(&id).map_or(value, String::as_str) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::models::SearchQuery; + + fn app_with_a_profile() -> ConfiguratorApp { + let (mut app, _effects) = ConfiguratorApp::new_app(); + let profile = app.draft.render_profiles.new_profile(); + app.draft.render_profiles.profiles.push(profile); + app + } + + /// The law the binding rests on: one layout per profile, and a row's + /// values carry exactly the mappings that layout built rows for. + #[test] + fn a_row_carries_one_value_per_mapping_row_the_layout_built() { + let app = app_with_a_profile(); + let layouts = section_layouts(&app, &app.search_summary()); + + assert_eq!(layouts.len(), app.draft.render_profiles.profiles.len()); + for (profile, layout) in app + .draft + .render_profiles + .profiles + .iter() + .zip(layouts.iter()) + { + assert_eq!(profile.mappings.len(), layout.mappings.len()); + } + } + + /// The caret guarantee, stated as the layout law it rests on: no keystroke + /// in a profile's text may rebuild the row it lands in. + #[test] + fn typing_into_a_profile_field_leaves_the_layout_alone() { + let mut app = app_with_a_profile(); + let before = section_layouts(&app, &app.search_summary()); + + let Some(profile) = app.draft.render_profiles.profiles.first_mut() else { + return; + }; + profile.name = "Half typ".to_string(); + profile.id = "half-typ".to_string(); + if let Some(mapping) = profile.mappings.first_mut() { + mapping.from = "#00FF0".to_string(); + } + + assert_eq!(before, section_layouts(&app, &app.search_summary())); + assert_eq!(app.draft.render_profiles.profiles[0].id, "half-typ"); + } + + #[test] + fn adding_a_mapping_changes_the_layout() { + let mut app = app_with_a_profile(); + let before = section_layouts(&app, &app.search_summary()); + + let Some(profile) = app.draft.render_profiles.profiles.first_mut() else { + return; + }; + let Some(mapping) = profile.mappings.first().cloned() else { + return; + }; + profile.mappings.push(mapping); + + assert_ne!(before, section_layouts(&app, &app.search_summary())); + } + + /// Search visibility belongs to the layout too: a rebuild is what applies + /// it now that nothing refreshes a section in place. + #[test] + fn a_search_that_hides_a_profile_changes_the_layout() { + let mut app = app_with_a_profile(); + let second = app.draft.render_profiles.new_profile(); + app.draft.render_profiles.profiles.push(second); + let Some(profile) = app.draft.render_profiles.profiles.first_mut() else { + return; + }; + profile.name = "zqxwvu".to_string(); + let before = section_layouts(&app, &app.search_summary()); + + app.search_query = SearchQuery::new("zqxwvu"); + let layouts = section_layouts(&app, &app.search_summary()); + + assert_ne!(before, layouts); + let visible: Vec = layouts.iter().map(|layout| layout.visible).collect(); + assert_eq!(visible.first(), Some(&true)); + assert!(visible.iter().skip(1).all(|visible| !visible)); + } +} diff --git a/configurator/src/app/pages/session.rs b/configurator/src/app/pages/session.rs new file mode 100644 index 00000000..d5c12a6c --- /dev/null +++ b/configurator/src/app/pages/session.rs @@ -0,0 +1,855 @@ +//! Session page: persistence settings and the saved-session catalog. +//! +//! The settings half is ordinary `PageBuilder` rows. The catalog half is a +//! list whose length the model owns, so each built card owns the typed layout +//! that produced it and its refresh closure. A card and its refresh therefore +//! cannot drift apart, and nothing has to rediscover a control by name or +//! position. Everything that changes without changing what a rebuilt +//! card would render — entry contents, button sensitivity, the two-step +//! clear, search filtering — is written in place by those closures, with the +//! entry handlers blocked so a refresh is never mistaken for typing. + +use relm4::prelude::*; +use relm4::{adw, gtk}; + +use adw::prelude::*; +use gtk::glib::SignalHandlerId; + +use crate::messages::Message; +use crate::models::{ + SessionCatalogItem, SessionCatalogOperation, SessionCompressionOption, + SessionStorageModeOption, TabId, TextField, ToggleField, +}; + +use super::super::search::{AppSearchSummary, SearchArea}; +use super::super::state::ConfiguratorApp; +use super::{BuiltPage, PageBuilder, set_text_blocked}; + +pub(super) fn build(sender: &ComponentSender) -> BuiltPage { + let mut page = PageBuilder::new(sender, TabId::Session); + + page.group_in_area("Session Persistence", SearchArea::SessionPersistence) + .switch_row( + "Persist transparent mode drawings", + "", + |app| app.draft.session_persist_transparent, + |value| Message::ToggleChanged(ToggleField::SessionPersistTransparent, value), + ) + .switch_row( + "Persist whiteboard mode drawings", + "", + |app| app.draft.session_persist_whiteboard, + |value| Message::ToggleChanged(ToggleField::SessionPersistWhiteboard, value), + ) + .switch_row( + "Persist blackboard mode drawings", + "", + |app| app.draft.session_persist_blackboard, + |value| Message::ToggleChanged(ToggleField::SessionPersistBlackboard, value), + ) + .switch_row( + "Persist undo/redo history", + "", + |app| app.draft.session_persist_history, + |value| Message::ToggleChanged(ToggleField::SessionPersistHistory, value), + ) + .switch_row( + "Restore tool state on startup", + "", + |app| app.draft.session_restore_tool_state, + |value| Message::ToggleChanged(ToggleField::SessionRestoreToolState, value), + ) + .switch_row( + "Per-output persistence", + "", + |app| app.draft.session_per_output, + |value| Message::ToggleChanged(ToggleField::SessionPerOutput, value), + ); + + page.group_in_area("Storage", SearchArea::SessionPersistence) + .combo_row( + "Storage mode", + "", + SessionStorageModeOption::list(), + option_labels(SessionStorageModeOption::list(), |option| option.label()), + |app| app.draft.session_storage_mode, + Message::SessionStorageModeChanged, + ); + custom_directory_row(&mut page); + page.combo_row( + "Compression", + "", + SessionCompressionOption::list(), + option_labels(SessionCompressionOption::list(), |option| option.label()), + |app| app.draft.session_compression, + Message::SessionCompressionChanged, + ) + .entry_row_validated( + "Max shapes per frame", + |app| app.draft.session_max_shapes_per_frame.clone(), + |value| Message::TextChanged(TextField::SessionMaxShapesPerFrame, value), + |app| validate_whole_number(&app.draft.session_max_shapes_per_frame, 1, u64::MAX), + ) + .entry_row( + "Max persisted undo depth (blank = runtime limit)", + |app| app.draft.session_max_persisted_undo_depth.clone(), + |value| Message::TextChanged(TextField::SessionMaxPersistedUndoDepth, value), + ) + .entry_row_validated( + "Max file size (MB)", + |app| app.draft.session_max_file_size_mb.clone(), + |value| Message::TextChanged(TextField::SessionMaxFileSizeMb, value), + |app| validate_whole_number(&app.draft.session_max_file_size_mb, 1, 1024), + ) + .entry_row_validated( + "Auto-compress threshold (KB)", + |app| app.draft.session_auto_compress_threshold_kb.clone(), + |value| Message::TextChanged(TextField::SessionAutoCompressThresholdKb, value), + |app| validate_whole_number(&app.draft.session_auto_compress_threshold_kb, 1, u64::MAX), + ) + .entry_row( + "Backup retention count", + |app| app.draft.session_backup_retention.clone(), + |value| Message::TextChanged(TextField::SessionBackupRetention, value), + ); + + page.group_in_area("Autosave", SearchArea::SessionPersistence) + .switch_row( + "Enable autosave", + "", + |app| app.draft.session_autosave_enabled, + |value| Message::ToggleChanged(ToggleField::SessionAutosaveEnabled, value), + ) + .entry_row_validated( + "Autosave idle (ms)", + |app| app.draft.session_autosave_idle_ms.clone(), + |value| Message::TextChanged(TextField::SessionAutosaveIdleMs, value), + |app| validate_whole_number(&app.draft.session_autosave_idle_ms, 1000, u64::MAX), + ) + .entry_row_validated( + "Autosave interval (ms)", + |app| app.draft.session_autosave_interval_ms.clone(), + |value| Message::TextChanged(TextField::SessionAutosaveIntervalMs, value), + |app| validate_whole_number(&app.draft.session_autosave_interval_ms, 1000, u64::MAX), + ) + .entry_row_validated( + "Autosave failure backoff (ms)", + |app| app.draft.session_autosave_failure_backoff_ms.clone(), + |value| Message::TextChanged(TextField::SessionAutosaveFailureBackoffMs, value), + |app| { + validate_whole_number( + &app.draft.session_autosave_failure_backoff_ms, + 1000, + u64::MAX, + ) + }, + ); + + page.group_in_area("Saved Sessions", SearchArea::SessionCatalog); + catalog_section(&mut page); + + page.finish() +} + +fn option_labels(options: Vec, label: impl Fn(&O) -> &'static str) -> Vec { + options + .iter() + .map(|option| label(option).to_string()) + .collect() +} + +/// The custom directory only applies to one storage mode, so it is a row the +/// mode shows rather than a row that is always there and usually inert. +fn custom_directory_row(page: &mut PageBuilder) { + let row = adw::EntryRow::builder().title("Custom directory").build(); + let handler = { + let sender = page.sender(); + row.connect_changed(move |row| { + sender.input(Message::TextChanged( + TextField::SessionCustomDirectory, + row.text().to_string(), + )); + }) + }; + page.custom(&row); + page.bind(move |app, _summary| { + set_visible( + &row, + app.draft.session_storage_mode == SessionStorageModeOption::Custom, + ); + // Blocked: the model owns this text, and a load reporting its own + // value back as a user edit clears the diagnostics that load produced. + set_text_blocked(&row, &handler, &app.draft.session_custom_directory); + }); +} + +// ---- Catalog ----------------------------------------------------------- + +fn catalog_section(page: &mut PageBuilder) { + let sender = page.sender(); + + let body = column_box(); + let refresh = message_button("Refresh", &sender, Message::SessionCatalogRefreshRequested); + let toolbar = row_box(); + toolbar.append(&refresh); + body.append(&toolbar); + body.append(&hint_label( + "Clear Tool State applies config defaults without deleting boards.", + )); + body.append(&hint_label("Clear Saved Data removes saved session files.")); + + let blocker_label = warning_label(""); + body.append(&blocker_label); + let loading_label = body_label("Loading sessions..."); + body.append(&loading_label); + let empty_label = body_label("No named sessions in the catalog yet."); + body.append(&empty_label); + + let list = gtk::Box::builder() + .orientation(gtk::Orientation::Vertical) + .spacing(12) + .build(); + body.append(&list); + + page.custom(&body); + let mut cards: Vec = Vec::new(); + page.bind(move |app, summary| { + let catalog = &app.session_catalog; + let gates = CatalogGates::of(app); + set_sensitive(&refresh, !gates.busy); + + match SessionCatalogOperation::Clear.cached_status_blocker(app.daemon_status.as_ref()) { + Some(blocker) => { + set_label(&blocker_label, blocker); + set_visible(&blocker_label, true); + } + None => set_visible(&blocker_label, false), + } + set_visible(&loading_label, catalog.is_loading); + set_visible( + &empty_label, + !catalog.is_loading && catalog.items.is_empty(), + ); + + let layout = catalog_layout(app); + if !cards + .iter() + .map(|card| &card.layout) + .eq(layout.items.iter()) + { + cards = rebuild_items(&list, &layout, &sender); + } + for (item, card) in catalog.items.iter().zip(cards.iter()) { + let values = catalog_row_values(app, summary, &gates, item); + (card.refresh)(&values); + } + }); +} + +/// Everything a rebuilt card would render, and nothing else. +/// +/// Entry contents, button states, and the two-step clear are deliberately +/// absent: those are written in place, so typing never destroys the entry +/// being typed into. +#[derive(Debug, Default, Clone, PartialEq, Eq)] +struct CatalogLayout { + /// Empty while loading: the list renders nothing then, so there is + /// nothing to build and nothing to refresh. + items: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct CatalogItemLayout { + id: String, + display_name: String, + path_label: String, + canonical_path_label: Option, + created_label: String, + last_opened_label: String, + last_saved_label: String, + artifacts: String, +} + +fn catalog_layout(app: &ConfiguratorApp) -> CatalogLayout { + if app.session_catalog.is_loading { + return CatalogLayout::default(); + } + + CatalogLayout { + items: app + .session_catalog + .items + .iter() + .map(|item| CatalogItemLayout { + id: item.id.clone(), + display_name: item.display_name.clone(), + path_label: item.path_label.clone(), + canonical_path_label: item.canonical_path_label.clone(), + created_label: item.created_label.clone(), + last_opened_label: item.last_opened_label.clone(), + last_saved_label: item.last_saved_label.clone(), + artifacts: item.artifacts.status_label(), + }) + .collect(), + } +} + +/// What a card's controls are allowed to do, resolved once per refresh: the +/// blockers are the same answer for every row and cost a lookup each. +struct CatalogGates { + busy: bool, + duplicate_blocked: bool, + move_blocked: bool, + tool_state_blocked: bool, + clear_blocked: bool, +} + +impl CatalogGates { + fn of(app: &ConfiguratorApp) -> Self { + let status = app.daemon_status.as_ref(); + Self { + busy: app.session_catalog.busy || app.session_catalog.is_loading, + duplicate_blocked: SessionCatalogOperation::Duplicate + .cached_status_blocker(status) + .is_some(), + move_blocked: SessionCatalogOperation::Move + .cached_status_blocker(status) + .is_some(), + tool_state_blocked: SessionCatalogOperation::ClearToolState + .cached_status_blocker(status) + .is_some(), + clear_blocked: SessionCatalogOperation::Clear + .cached_status_blocker(status) + .is_some(), + } + } +} + +/// One card's values: the model-owned entry text and every action's state. +struct CatalogRowValues { + visible: bool, + rename: String, + rename_enabled: bool, + duplicate: String, + duplicate_enabled: bool, + move_target: String, + move_enabled: bool, + /// Reveal and Forget, which only wait on the catalog being idle. + actions_enabled: bool, + tool_state_enabled: bool, + clear_enabled: bool, + clear_armed: bool, +} + +fn catalog_row_values( + app: &ConfiguratorApp, + summary: &AppSearchSummary, + gates: &CatalogGates, + item: &SessionCatalogItem, +) -> CatalogRowValues { + let catalog = &app.session_catalog; + let id = item.id.as_str(); + let rename = catalog.rename_value(id, &item.display_name); + let duplicate = catalog.duplicate_value(id, &item.path); + let move_target = catalog.move_value(id, &item.path); + CatalogRowValues { + visible: item_visible(summary, id), + rename_enabled: !gates.busy + && rename.trim() != item.display_name.trim() + && !rename.trim().is_empty(), + duplicate_enabled: !gates.busy && !gates.duplicate_blocked && !duplicate.trim().is_empty(), + move_enabled: !gates.busy && !gates.move_blocked && !move_target.trim().is_empty(), + actions_enabled: !gates.busy, + tool_state_enabled: !gates.busy && !gates.tool_state_blocked, + clear_enabled: !gates.busy && !gates.clear_blocked, + clear_armed: clear_armed(catalog.pending_clear_id.as_deref(), id), + rename, + duplicate, + move_target, + } +} + +/// One card's refresh: built beside its card, so it owns that card's typed +/// widget handles and the signal handler ids guarding each write. +type CatalogRowRefresh = Box; + +struct BoundCatalogCard { + layout: CatalogItemLayout, + refresh: CatalogRowRefresh, +} + +fn rebuild_items( + list: >k::Box, + layout: &CatalogLayout, + sender: &ComponentSender, +) -> Vec { + // Draining the list, not walking it for a control: the cards that replace + // these carry their own refresh closures. + while let Some(child) = list.first_child() { + list.remove(&child); + } + + let mut cards = Vec::with_capacity(layout.items.len()); + for item in &layout.items { + let built = item_card(item, sender); + list.append(&built.card); + cards.push(BoundCatalogCard { + layout: item.clone(), + refresh: built.refresh, + }); + } + cards +} + +/// One catalog card: the widget, and the closure that writes the values the +/// layout deliberately left out. +struct CatalogRow { + card: gtk::Box, + refresh: CatalogRowRefresh, +} + +fn item_card(item: &CatalogItemLayout, sender: &ComponentSender) -> CatalogRow { + let card = gtk::Box::builder() + .orientation(gtk::Orientation::Vertical) + .css_classes(["card"]) + .build(); + + let content = gtk::Box::builder() + .orientation(gtk::Orientation::Vertical) + .spacing(8) + .margin_top(12) + .margin_bottom(12) + .margin_start(12) + .margin_end(12) + .build(); + card.append(&content); + + let header = row_box(); + let name = body_label(&item.display_name); + name.add_css_class("heading"); + header.append(&name); + header.append(&hint_label(&item.artifacts)); + content.append(&header); + + content.append(&caption_label(&item.path_label)); + if let Some(canonical) = item.canonical_path_label.as_deref() { + content.append(&caption_label(&format!("Canonical: {canonical}"))); + } + let times = row_box(); + times.append(&hint_label(&format!("Created: {}", item.created_label))); + times.append(&hint_label(&format!("Opened: {}", item.last_opened_label))); + times.append(&hint_label(&format!("Saved: {}", item.last_saved_label))); + content.append(×); + + let rename = input_row( + InputRow { + id: &item.id, + placeholder: "Display name", + button_label: "Save Name", + on_input: Message::SessionCatalogRenameInputChanged, + request: Message::SessionCatalogRenameRequested(item.id.clone()), + }, + sender, + ); + content.append(&rename.container); + let duplicate = input_row( + InputRow { + id: &item.id, + placeholder: "Duplicate target path", + button_label: "Duplicate", + on_input: Message::SessionCatalogDuplicateInputChanged, + request: Message::SessionCatalogDuplicateRequested(item.id.clone()), + }, + sender, + ); + content.append(&duplicate.container); + let move_row = input_row( + InputRow { + id: &item.id, + placeholder: "Move target path", + button_label: "Move", + on_input: Message::SessionCatalogMoveInputChanged, + request: Message::SessionCatalogMoveRequested(item.id.clone()), + }, + sender, + ); + content.append(&move_row.container); + + let actions = row_box(); + let reveal = message_button( + "Reveal File", + sender, + Message::SessionCatalogRevealRequested(item.id.clone()), + ); + actions.append(&reveal); + let tool_state = message_button( + "Clear Tool State", + sender, + Message::SessionCatalogClearToolStateRequested(item.id.clone()), + ); + actions.append(&tool_state); + content.append(&actions); + + let danger = row_box(); + let clear = message_button( + "Clear Saved Data", + sender, + Message::SessionCatalogClearRequested(item.id.clone()), + ); + clear.add_css_class("destructive-action"); + danger.append(&clear); + + // Both halves of the two-step clear exist from the start; arming the + // pending id swaps which one is visible. + let confirm = row_box(); + let confirm_button = message_button( + "Confirm Clear", + sender, + Message::SessionCatalogClearConfirmed(item.id.clone()), + ); + confirm_button.add_css_class("destructive-action"); + confirm.append(&confirm_button); + let cancel_button = message_button("Cancel", sender, Message::SessionCatalogClearCanceled); + cancel_button.add_css_class("flat"); + confirm.append(&cancel_button); + danger.append(&confirm); + + let forget = message_button( + "Forget", + sender, + Message::SessionCatalogForgetRequested(item.id.clone()), + ); + forget.add_css_class("flat"); + danger.append(&forget); + content.append(&danger); + + let handle = card.clone(); + let refresh: CatalogRowRefresh = Box::new(move |values| { + set_visible(&handle, values.visible); + + // Blocked: these entries carry text the model owns, and a refresh + // reporting it back as typing would pin an input the user never made. + set_text_blocked(&rename.entry, &rename.handler, &values.rename); + set_sensitive(&rename.button, values.rename_enabled); + set_text_blocked(&duplicate.entry, &duplicate.handler, &values.duplicate); + set_sensitive(&duplicate.button, values.duplicate_enabled); + set_text_blocked(&move_row.entry, &move_row.handler, &values.move_target); + set_sensitive(&move_row.button, values.move_enabled); + + set_sensitive(&reveal, values.actions_enabled); + set_sensitive(&forget, values.actions_enabled); + set_sensitive(&tool_state, values.tool_state_enabled); + + set_visible(&clear, !values.clear_armed); + set_sensitive(&clear, values.clear_enabled); + set_visible(&confirm, values.clear_armed); + }); + + CatalogRow { card, refresh } +} + +struct InputRow<'a> { + id: &'a str, + placeholder: &'a str, + button_label: &'a str, + on_input: fn(String, String) -> Message, + request: Message, +} + +/// An entry the model owns beside the button that acts on it, kept with the +/// handler a refresh has to block before writing the entry. +struct InputRowWidgets { + container: gtk::Box, + entry: gtk::Entry, + handler: SignalHandlerId, + button: gtk::Button, +} + +fn input_row(row: InputRow<'_>, sender: &ComponentSender) -> InputRowWidgets { + let container = row_box(); + let entry = gtk::Entry::builder() + .hexpand(true) + .placeholder_text(row.placeholder) + .build(); + let handler = { + let sender = sender.clone(); + let id = row.id.to_string(); + let on_input = row.on_input; + entry.connect_changed(move |entry| { + sender.input(on_input(id.clone(), entry.text().to_string())); + }) + }; + container.append(&entry); + + let button = message_button(row.button_label, sender, row.request); + container.append(&button); + InputRowWidgets { + container, + entry, + handler, + button, + } +} + +fn clear_armed(pending: Option<&str>, id: &str) -> bool { + pending == Some(id) +} + +fn item_visible(summary: &AppSearchSummary, id: &str) -> bool { + !summary.is_active() + || summary + .tab(TabId::Session) + .is_none_or(|tab| tab.session_item_visible(id)) +} + +/// Error text for a whole-number field, `None` while the input is +/// acceptable. `u64::MAX` as the upper bound means "no maximum" and reports +/// only the minimum, matching the Iced view's two validators. +fn validate_whole_number(value: &str, min: u64, max: u64) -> Option { + let Ok(parsed) = value.trim().parse::() else { + return Some("Expected a whole number".to_string()); + }; + if (min..=max).contains(&parsed) { + return None; + } + Some(if max == u64::MAX { + format!("Minimum: {min}") + } else { + format!("Range: {min}-{max}") + }) +} + +// ---- Widget helpers ---------------------------------------------------- + +fn column_box() -> gtk::Box { + gtk::Box::builder() + .orientation(gtk::Orientation::Vertical) + .spacing(8) + .build() +} + +fn row_box() -> gtk::Box { + gtk::Box::builder() + .orientation(gtk::Orientation::Horizontal) + .spacing(8) + .build() +} + +fn body_label(text: &str) -> gtk::Label { + gtk::Label::builder() + .label(text) + .xalign(0.0) + .wrap(true) + .halign(gtk::Align::Start) + .valign(gtk::Align::Center) + .build() +} + +fn caption_label(text: &str) -> gtk::Label { + let label = body_label(text); + label.add_css_class("caption"); + label +} + +fn hint_label(text: &str) -> gtk::Label { + let label = caption_label(text); + label.add_css_class("dim-label"); + label +} + +fn warning_label(text: &str) -> gtk::Label { + let label = caption_label(text); + label.add_css_class("warning"); + label +} + +fn message_button( + label: &str, + sender: &ComponentSender, + message: Message, +) -> gtk::Button { + let button = gtk::Button::builder() + .label(label) + .valign(gtk::Align::Center) + .build(); + let sender = sender.clone(); + button.connect_clicked(move |_| sender.input(message.clone())); + button +} + +fn set_label(label: >k::Label, text: &str) { + if label.label() != text { + label.set_label(text); + } +} + +/// Writes the widget's own visibility flag, never `is_visible`: a row inside +/// a hidden group reports invisible while its own flag still says otherwise, +/// and skipping the write there would leak the stale state the moment the +/// group comes back. +fn set_visible(widget: &impl IsA, visible: bool) { + if widget.get_visible() != visible { + widget.set_visible(visible); + } +} + +fn set_sensitive(widget: &impl IsA, sensitive: bool) { + if widget.is_sensitive() != sensitive { + widget.set_sensitive(sensitive); + } +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use super::*; + use crate::models::SessionCatalogItem; + use crate::models::session::SessionArtifactSummary; + + fn test_item(id: &str, display_name: &str) -> SessionCatalogItem { + SessionCatalogItem { + id: id.to_string(), + display_name: display_name.to_string(), + path: PathBuf::from(format!("/tmp/{id}.wayscriber-session")), + path_label: format!("/tmp/{id}.wayscriber-session"), + canonical_path_label: None, + created_label: "2026-01-01 10:00".to_string(), + last_opened_label: "Never".to_string(), + last_saved_label: "Never".to_string(), + artifacts: SessionArtifactSummary { + primary_exists: true, + backup_exists: false, + recovery_exists: false, + clear_marker_exists: false, + lock_exists: false, + non_lock_size_bytes: 2048, + }, + } + } + + fn app_with_items(items: Vec) -> ConfiguratorApp { + let (mut app, _effects) = ConfiguratorApp::new_app(); + app.session_catalog.replace_items(items); + app + } + + /// The caret guarantee: typing in a card's entry, arming the clear, or a + /// running action must not rebuild the card being typed into. + #[test] + fn layout_ignores_input_edits_and_pending_clear() { + let mut app = app_with_items(vec![test_item("one", "First")]); + let baseline = catalog_layout(&app); + + app.session_catalog + .rename_inputs + .insert("one".to_string(), "First draft".to_string()); + app.session_catalog.pending_clear_id = Some("one".to_string()); + app.session_catalog.busy = true; + + assert_eq!(catalog_layout(&app), baseline); + // The edit rides the card's values instead, where a blocked write can + // put it in the entry without a rebuild. + let summary = app.search_summary(); + let values = catalog_row_values( + &app, + &summary, + &CatalogGates::of(&app), + &app.session_catalog.items[0], + ); + assert_eq!(values.rename, "First draft"); + } + + #[test] + fn layout_tracks_rendered_item_text() { + let renamed = catalog_layout(&app_with_items(vec![test_item("one", "Renamed")])); + let original = catalog_layout(&app_with_items(vec![test_item("one", "First")])); + assert_ne!(renamed, original); + + let two_items = catalog_layout(&app_with_items(vec![ + test_item("one", "First"), + test_item("two", "Second"), + ])); + assert_ne!(two_items, original); + } + + #[test] + fn loading_and_an_empty_catalog_both_build_no_cards() { + let empty = app_with_items(Vec::new()); + let loading = ConfiguratorApp::new_app().0; + + assert!(loading.session_catalog.is_loading); + // Nothing is rendered while loading, so nothing is built for it and + // there is no card left over to refresh. + assert!(catalog_layout(&empty).items.is_empty()); + assert!(catalog_layout(&loading).items.is_empty()); + } + + /// A card's buttons follow the model, and the rename button waits for an + /// edit that is actually a change. + #[test] + fn a_busy_catalog_leaves_every_action_unpressable() { + let mut app = app_with_items(vec![test_item("one", "First")]); + let summary = app.search_summary(); + let idle = catalog_row_values( + &app, + &summary, + &CatalogGates::of(&app), + &app.session_catalog.items[0], + ); + assert!(idle.actions_enabled); + assert!(!idle.rename_enabled, "an unchanged name is not a rename"); + + app.session_catalog + .rename_inputs + .insert("one".to_string(), "Second".to_string()); + let changed = catalog_row_values( + &app, + &summary, + &CatalogGates::of(&app), + &app.session_catalog.items[0], + ); + assert!(changed.rename_enabled); + + app.session_catalog.busy = true; + let busy = catalog_row_values( + &app, + &summary, + &CatalogGates::of(&app), + &app.session_catalog.items[0], + ); + assert!(!busy.actions_enabled); + assert!(!busy.rename_enabled); + assert!(!busy.duplicate_enabled); + assert!(!busy.move_enabled); + } + + #[test] + fn clear_confirmation_is_armed_for_one_row_only() { + assert!(clear_armed(Some("one"), "one")); + assert!(!clear_armed(Some("one"), "two")); + assert!(!clear_armed(None, "one")); + } + + #[test] + fn whole_number_validation_matches_the_old_hints() { + assert_eq!(validate_whole_number("1000", 1000, u64::MAX), None); + assert_eq!( + validate_whole_number("999", 1000, u64::MAX).as_deref(), + Some("Minimum: 1000") + ); + assert_eq!( + validate_whole_number("", 1, u64::MAX).as_deref(), + Some("Expected a whole number") + ); + assert_eq!( + validate_whole_number("-4", 1, u64::MAX).as_deref(), + Some("Expected a whole number") + ); + assert_eq!(validate_whole_number(" 512 ", 1, 1024), None); + assert_eq!( + validate_whole_number("2048", 1, 1024).as_deref(), + Some("Range: 1-1024") + ); + } +} diff --git a/configurator/src/app/pages/tablet.rs b/configurator/src/app/pages/tablet.rs new file mode 100644 index 00000000..860ed98a --- /dev/null +++ b/configurator/src/app/pages/tablet.rs @@ -0,0 +1,97 @@ +//! Tablet page: stylus input and the pressure-to-thickness mapping. +//! +//! Compiled only with the `tablet-input` feature, the same gate the draft +//! fields and both pressure messages sit behind. + +use relm4::prelude::*; + +use crate::messages::Message; +use crate::models::{ + PressureThicknessEditModeOption, PressureThicknessEntryModeOption, TabId, TextField, + ToggleField, +}; + +use super::super::search::SearchArea; +use super::super::state::ConfiguratorApp; +use super::capture::validate_f64_range; +use super::{BuiltPage, PageBuilder}; + +pub(super) fn build(sender: &ComponentSender) -> BuiltPage { + let edit_modes = PressureThicknessEditModeOption::list(); + let edit_mode_labels = edit_modes + .iter() + .map(|mode| mode.label().to_string()) + .collect(); + let entry_modes = PressureThicknessEntryModeOption::list(); + let entry_mode_labels = entry_modes + .iter() + .map(|mode| mode.label().to_string()) + .collect(); + + let mut page = PageBuilder::new(sender, TabId::Tablet); + + page.group_in_area("Stylus", SearchArea::Tablet) + .switch_row( + "Enable tablet input", + "", + |app| app.draft.tablet_enabled, + |value| Message::ToggleChanged(ToggleField::TabletEnabled, value), + ) + .switch_row( + "Enable pressure-to-thickness", + "", + |app| app.draft.tablet_pressure_enabled, + |value| Message::ToggleChanged(ToggleField::TabletPressureEnabled, value), + ) + .switch_row( + "Auto-switch to eraser", + "", + |app| app.draft.tablet_auto_eraser_switch, + |value| Message::ToggleChanged(ToggleField::TabletAutoEraserSwitch, value), + ); + + page.group_in_area("Pressure", SearchArea::Tablet) + .entry_row_validated( + "Min thickness", + |app| app.draft.tablet_min_thickness.clone(), + |value| Message::TextChanged(TextField::TabletMinThickness, value), + |app| validate_f64_range(&app.draft.tablet_min_thickness, 1.0, 50.0), + ) + .entry_row_validated( + "Max thickness", + |app| app.draft.tablet_max_thickness.clone(), + |value| Message::TextChanged(TextField::TabletMaxThickness, value), + |app| validate_f64_range(&app.draft.tablet_max_thickness, 1.0, 50.0), + ) + // The Iced view showed a "Minimum: 0" hint here but never marked the + // field in error; the config clamps whatever arrives. + .entry_row( + "Pressure variation threshold", + |app| app.draft.tablet_pressure_variation_threshold.clone(), + |value| Message::TextChanged(TextField::TabletPressureVariationThreshold, value), + ) + .entry_row_validated( + "Pressure thickness scale step", + |app| app.draft.tablet_pressure_thickness_scale_step.clone(), + |value| Message::TextChanged(TextField::TabletPressureScaleStep, value), + |app| validate_f64_range(&app.draft.tablet_pressure_thickness_scale_step, 0.0, 1.0), + ) + .combo_row( + "Pressure thickness edit mode", + "", + edit_modes, + edit_mode_labels, + |app| app.draft.tablet_pressure_thickness_edit_mode, + Message::TabletPressureEditModeChanged, + ) + .combo_row( + "Pressure thickness entry mode", + "", + entry_modes, + entry_mode_labels, + |app| app.draft.tablet_pressure_thickness_entry_mode, + Message::TabletPressureEntryModeChanged, + ); + + page.finish() +} diff --git a/configurator/src/app/pages/ui.rs b/configurator/src/app/pages/ui.rs new file mode 100644 index 00000000..535ac127 --- /dev/null +++ b/configurator/src/app/pages/ui.rs @@ -0,0 +1,1278 @@ +//! UI page: general chrome settings plus the seven UI sub-pages. +//! +//! This page is bigger than one preferences page, so it composes its own +//! root: a "General UI" preferences page above a stack holding one +//! preferences page per [`UiTabId`]. Which sub-page shows is model state — +//! the switcher only sends [`Message::UiTabSelected`], and a binding drives +//! the visible child from `active_ui_tab`, so a deep link and the search +//! realignment that moves that field both land on the right sub-page. + +use relm4::prelude::*; +use relm4::{adw, gtk}; + +use adw::prelude::*; +use gtk::glib; + +use wayscriber::config::{ + ResolvedToolbarItems, ToolbarItemCategory, ToolbarItemDefinition, ToolbarItemId, + ToolbarItemOrderGroup, ToolbarItemSurface, ToolbarItemsConfig, toolbar_item_definitions, + toolbar_item_ids, toolbar_item_order_group, +}; + +use crate::messages::Message; +use crate::models::color::parse_quad_values; +use crate::models::{ + ColorPickerId, InputHudModeOption, InputHudPositionOption, OverrideOption, + PresenterToolBehaviorOption, PresenterToolbarModeOption, ReducedMotionOption, + StatusPositionOption, TabId, TextField, ToggleField, ToolbarLayoutModeOption, + ToolbarOverrideField, ToolbarRebindModifierOption, ToolbarSideLayoutOption, UiTabId, + UiThemeOption, ZoomChipDisplayOption, +}; + +use super::super::search::SearchArea; +use super::super::state::ConfiguratorApp; +use super::color_rows::{ResolvedColor, color_row}; +use super::{Binding, BuiltPage, PageBuilder, validate_u32_range}; + +pub(super) fn build(sender: &ComponentSender) -> BuiltPage { + let mut bindings: Vec = Vec::new(); + + let general = build_general(sender); + bindings.extend(general.bindings); + let general_widget = general.widget; + // Natural height inside the shared scroller: the general section and the + // active sub-page scroll together the way the Iced column did. + general_widget.set_vexpand(false); + { + let widget = general_widget.clone(); + bindings.push(Box::new(move |_app, summary| { + let visible = !summary.is_active() + || summary + .tab(TabId::Ui) + .is_some_and(|tab| tab.area_matches(SearchArea::UiGeneral)); + if widget.is_visible() != visible { + widget.set_visible(visible); + } + })); + } + + // Homogeneous sizing would make every sub-page as tall as the tallest + // one (toolbar visibility lists every item), so measure the visible + // child only. + let stack = gtk::Stack::builder() + .transition_type(gtk::StackTransitionType::Crossfade) + .vhomogeneous(false) + .vexpand(true) + .build(); + + let mut stack_pages: Vec<(UiTabId, gtk::StackPage)> = Vec::new(); + for tab in UiTabId::ALL { + let built = build_ui_tab(sender, tab); + bindings.extend(built.bindings); + built.widget.set_vexpand(false); + let stack_page = stack.add_titled(&built.widget, Some(tab.title()), tab.title()); + stack_pages.push((tab, stack_page)); + } + { + let sender = sender.clone(); + stack.connect_visible_child_name_notify(move |stack| { + let Some(name) = stack.visible_child_name() else { + return; + }; + if let Some(tab) = UiTabId::ALL.into_iter().find(|tab| tab.title() == name) { + sender.input(Message::UiTabSelected(tab)); + } + }); + } + + let switcher = gtk::StackSwitcher::builder() + .stack(&stack) + .halign(gtk::Align::Center) + .build(); + // Seven sub-tabs are wider than a narrow window: scrolling the row keeps + // the switcher off the page's minimum width, so the sections below stay + // centered instead of being pushed sideways. + let switcher_scroll = gtk::ScrolledWindow::builder() + .hscrollbar_policy(gtk::PolicyType::Automatic) + .vscrollbar_policy(gtk::PolicyType::Never) + .propagate_natural_height(true) + .margin_top(6) + .margin_start(12) + .margin_end(12) + .child(&switcher) + .build(); + + { + let stack = stack.clone(); + let switcher = switcher_scroll.clone(); + bindings.push(Box::new(move |app, summary| { + let ui_summary = summary.tab(TabId::Ui); + let searching = summary.is_active(); + let mut any_visible = false; + for (tab, stack_page) in &stack_pages { + // A hidden `GtkStackPage` also drops its switcher button, + // which is how the Iced view narrowed the sub-tab row. + let visible = + !searching || ui_summary.is_some_and(|summary| summary.ui_tab_visible(*tab)); + if stack_page.is_visible() != visible { + stack_page.set_visible(visible); + } + any_visible |= visible; + } + if switcher.is_visible() != any_visible { + switcher.set_visible(any_visible); + } + if stack.is_visible() != any_visible { + stack.set_visible(any_visible); + } + + let name = app.active_ui_tab.title(); + if stack.visible_child_name().as_deref() != Some(name) { + stack.set_visible_child_name(name); + } + })); + } + + let content = gtk::Box::new(gtk::Orientation::Vertical, 6); + content.append(&general_widget); + content.append(&switcher_scroll); + content.append(&stack); + + // The preferences pages carry scrollers of their own, whose minimum + // height is nearly zero, so a viewport on the default minimum policy + // would squeeze them instead of scrolling. Scrolling the natural height + // gives each section its full size and one scrollbar for the page. + let viewport = gtk::Viewport::builder() + .vscroll_policy(gtk::ScrollablePolicy::Natural) + .child(&content) + .build(); + let root = gtk::ScrolledWindow::builder() + .hscrollbar_policy(gtk::PolicyType::Automatic) + .vexpand(true) + .child(&viewport) + .build(); + + BuiltPage { + widget: root.upcast(), + bindings, + } +} + +fn build_ui_tab(sender: &ComponentSender, tab: UiTabId) -> BuiltPage { + match tab { + UiTabId::Toolbar => build_toolbar(sender), + UiTabId::ToolbarVisibility => build_toolbar_visibility(sender), + UiTabId::StatusBar => build_status_bar(sender), + UiTabId::HelpOverlay => build_help_overlay(sender), + UiTabId::ClickHighlight => build_click_highlight(sender), + UiTabId::InputHud => build_input_hud(sender), + UiTabId::PresenterMode => build_presenter_mode(sender), + } +} + +// ---- General --------------------------------------------------------------- + +fn build_general(sender: &ComponentSender) -> BuiltPage { + let (themes, theme_labels) = options(UiThemeOption::list(), |value| value.label()); + let (motions, motion_labels) = options(ReducedMotionOption::list(), |value| value.label()); + + let mut page = PageBuilder::new(sender, TabId::Ui); + page.group_in_area("General UI", SearchArea::UiGeneral) + .combo_row( + "Theme", + "\"Auto\" currently uses the dark theme; \"Light\" takes effect as overlay surfaces adopt the runtime theme.", + themes, + theme_labels, + |app| app.draft.ui_theme, + Message::UiThemeChanged, + ) + .combo_row( + "Reduced motion", + "\"On\" disables UI animations. \"Auto\" follows the system preference in a future release and keeps full motion for now.", + motions, + motion_labels, + |app| app.draft.ui_reduced_motion, + Message::UiReducedMotionChanged, + ) + .entry_row( + "Preferred output (GNOME fallback)", + |app| app.draft.ui_preferred_output.clone(), + |value| Message::TextChanged(TextField::UiPreferredOutput, value), + ) + .switch_row( + "Use fullscreen xdg fallback", + "Applies to the GNOME xdg-shell fallback overlay.", + |app| app.draft.ui_xdg_fullscreen, + |value| Message::ToggleChanged(ToggleField::UiXdgFullscreen, value), + ) + .switch_row( + "Keep open on xdg focus loss", + "", + |app| app.draft.ui_xdg_keep_on_focus_loss, + |value| Message::ToggleChanged(ToggleField::UiXdgKeepOnFocusLoss, value), + ) + .switch_row( + "Enable context menu", + "", + |app| app.draft.ui_context_menu_enabled, + |value| Message::ToggleChanged(ToggleField::UiContextMenuEnabled, value), + ) + .switch_row( + "Show capabilities warning toast", + "", + |app| app.draft.ui_show_capabilities_warning, + |value| Message::ToggleChanged(ToggleField::UiShowCapabilitiesWarning, value), + ) + .entry_row( + "Command palette toast (ms)", + |app| app.draft.ui_command_palette_toast_duration_ms.clone(), + |value| Message::TextChanged(TextField::UiCommandPaletteToastDurationMs, value), + ); + + page.finish() +} + +// ---- Toolbar --------------------------------------------------------------- + +fn build_toolbar(sender: &ComponentSender) -> BuiltPage { + let (layout_modes, layout_labels) = + options(ToolbarLayoutModeOption::list(), |value| value.label()); + let (side_layouts, side_layout_labels) = + options(ToolbarSideLayoutOption::list(), |value| value.label()); + let (zoom_chips, zoom_chip_labels) = + options(ZoomChipDisplayOption::list(), |value| value.label()); + let (rebinds, rebind_labels) = options(ToolbarRebindModifierOption::ALL.to_vec(), |value| { + value.label() + }); + let (override_modes, override_mode_labels) = + options(ToolbarLayoutModeOption::list(), |value| value.label()); + + let mut page = PageBuilder::new(sender, TabId::Ui); + + page.group("Toolbar").custom(¬e( + "These settings are configured defaults. Toolbar pins, position, display form, item visibility/order, pane state, and board pins changed in the overlay are saved separately as runtime preferences.", + )); + + page.group("Layout") + .combo_row( + "Layout mode", + "", + layout_modes, + layout_labels, + |app| app.draft.ui_toolbar_layout_mode, + Message::ToolbarLayoutModeChanged, + ) + .combo_row( + "Side layout", + "Pill (the default) retires the side palette: drawing properties live in the top strip's style pill, canvas management in the status HUD and board picker, and Session/Settings in popovers on the top strip's overflow menu. Panel is the legacy escape hatch restoring the classic side palette; it is deprecated and planned for removal one release after the pill default.", + side_layouts, + side_layout_labels, + |app| app.draft.ui_toolbar_side_layout, + Message::ToolbarSideLayoutChanged, + ) + .combo_row( + "Zoom chip", + "", + zoom_chips, + zoom_chip_labels, + |app| app.draft.ui_toolbar_zoom_chip_display, + Message::ToolbarZoomChipDisplayChanged, + ) + .switch_row( + "Show zoom chip", + "", + |app| app.draft.ui_toolbar_show_zoom_chip, + |value| Message::ToggleChanged(ToggleField::UiToolbarShowZoomChip, value), + ) + .combo_row( + "Shortcut edit click", + "", + rebinds, + rebind_labels, + |app| app.draft.ui_toolbar_rebind_modifier, + Message::ToolbarRebindModifierChanged, + ) + .switch_row( + "Configured default: pin top toolbar", + "", + |app| app.draft.ui_toolbar_top_pinned, + |value| Message::ToggleChanged(ToggleField::UiToolbarTopPinned, value), + ) + .switch_row( + "Configured default: pin side toolbar", + "", + |app| app.draft.ui_toolbar_side_pinned, + |value| Message::ToggleChanged(ToggleField::UiToolbarSidePinned, value), + ) + .switch_row( + "Use icon-only buttons", + "", + |app| app.draft.ui_toolbar_use_icons, + |value| Message::ToggleChanged(ToggleField::UiToolbarUseIcons, value), + ); + + page.group("Sections") + .switch_row( + "Show extended colors", + "", + |app| app.draft.ui_toolbar_show_more_colors, + |value| Message::ToggleChanged(ToggleField::UiToolbarShowMoreColors, value), + ) + .switch_row( + "Show presets", + "", + |app| app.draft.ui_toolbar_show_presets, + |value| Message::ToggleChanged(ToggleField::UiToolbarShowPresets, value), + ) + .switch_row( + "Show actions", + "", + |app| app.draft.ui_toolbar_show_actions_section, + |value| Message::ToggleChanged(ToggleField::UiToolbarShowActionsSection, value), + ) + .switch_row( + "Show zoom actions", + "", + |app| app.draft.ui_toolbar_show_zoom_actions, + |value| Message::ToggleChanged(ToggleField::UiToolbarShowZoomActions, value), + ) + .switch_row( + "Show advanced actions", + "", + |app| app.draft.ui_toolbar_show_actions_advanced, + |value| Message::ToggleChanged(ToggleField::UiToolbarShowActionsAdvanced, value), + ) + .switch_row( + "Show pages section", + "", + |app| app.draft.ui_toolbar_show_pages_section, + |value| Message::ToggleChanged(ToggleField::UiToolbarShowPagesSection, value), + ) + .switch_row( + "Show boards section", + "", + |app| app.draft.ui_toolbar_show_boards_section, + |value| Message::ToggleChanged(ToggleField::UiToolbarShowBoardsSection, value), + ) + .switch_row( + "Show multi-step undo/redo", + "", + |app| app.draft.ui_toolbar_show_step_section, + |value| Message::ToggleChanged(ToggleField::UiToolbarShowStepSection, value), + ) + .switch_row( + "Always show text controls", + "", + |app| app.draft.ui_toolbar_show_text_controls, + |value| Message::ToggleChanged(ToggleField::UiToolbarShowTextControls, value), + ) + .switch_row( + "Show delay sliders", + "", + |app| app.draft.ui_toolbar_show_delay_sliders, + |value| Message::ToggleChanged(ToggleField::UiToolbarShowDelaySliders, value), + ) + .switch_row( + "Show marker opacity controls", + "", + |app| app.draft.ui_toolbar_show_marker_opacity_section, + |value| Message::ToggleChanged(ToggleField::UiToolbarShowMarkerOpacitySection, value), + ) + .switch_row( + "Show tool preview bubble", + "", + |app| app.draft.ui_toolbar_show_tool_preview, + |value| Message::ToggleChanged(ToggleField::UiToolbarShowToolPreview, value), + ) + .switch_row( + "Show preset action toasts", + "", + |app| app.draft.ui_toolbar_show_preset_toasts, + |value| Message::ToggleChanged(ToggleField::UiToolbarPresetToasts, value), + ) + .switch_row( + "Force inline toolbars", + "", + |app| app.draft.ui_toolbar_force_inline, + |value| Message::ToggleChanged(ToggleField::UiToolbarForceInline, value), + ); + + page.group("Mode overrides").combo_row( + "Edit mode", + "Overrides below apply to the mode selected here; \"Default\" keeps the mode preset.", + override_modes, + override_mode_labels, + |app| app.override_mode, + Message::ToolbarOverrideModeChanged, + ); + for field in [ + ToolbarOverrideField::ShowPresets, + ToolbarOverrideField::ShowActionsSection, + ToolbarOverrideField::ShowZoomActions, + ToolbarOverrideField::ShowActionsAdvanced, + ToolbarOverrideField::ShowPagesSection, + ToolbarOverrideField::ShowBoardsSection, + ToolbarOverrideField::ShowStepSection, + ToolbarOverrideField::ShowTextControls, + ] { + let (values, labels) = options(OverrideOption::list(), |value| value.label()); + page.combo_row( + field.label(), + "", + values, + labels, + move |app| toolbar_override(app, field), + move |value| Message::ToolbarOverrideChanged(field, value), + ); + } + + page.group("Placement offsets") + .entry_row( + "Top offset X (px)", + |app| app.draft.ui_toolbar_top_offset.clone(), + |value| Message::TextChanged(TextField::ToolbarTopOffset, value), + ) + .entry_row( + "Top offset Y (px)", + |app| app.draft.ui_toolbar_top_offset_y.clone(), + |value| Message::TextChanged(TextField::ToolbarTopOffsetY, value), + ) + .entry_row( + "Side offset Y (px)", + |app| app.draft.ui_toolbar_side_offset.clone(), + |value| Message::TextChanged(TextField::ToolbarSideOffset, value), + ) + .entry_row( + "Side offset X (px)", + |app| app.draft.ui_toolbar_side_offset_x.clone(), + |value| Message::TextChanged(TextField::ToolbarSideOffsetX, value), + ) + .custom(¬e( + "Configured defaults. Dragging a toolbar in the overlay saves that position as a runtime preference; editing a value here takes over from the saved drag.", + )); + + page.finish() +} + +fn toolbar_override(app: &ConfiguratorApp, field: ToolbarOverrideField) -> OverrideOption { + let overrides = app + .draft + .ui_toolbar_mode_overrides + .for_mode(app.override_mode); + match field { + ToolbarOverrideField::ShowPresets => overrides.show_presets, + ToolbarOverrideField::ShowActionsSection => overrides.show_actions_section, + ToolbarOverrideField::ShowActionsAdvanced => overrides.show_actions_advanced, + ToolbarOverrideField::ShowZoomActions => overrides.show_zoom_actions, + ToolbarOverrideField::ShowPagesSection => overrides.show_pages_section, + ToolbarOverrideField::ShowBoardsSection => overrides.show_boards_section, + ToolbarOverrideField::ShowStepSection => overrides.show_step_section, + ToolbarOverrideField::ShowTextControls => overrides.show_text_controls, + } +} + +// ---- Toolbar visibility ---------------------------------------------------- + +/// One preferences group of item rows: a surface/category batch, or one of +/// the three order groups the configurator can reorder. +struct ItemSection { + title: String, + order_group: Option, + definitions: Vec<&'static ToolbarItemDefinition>, +} + +struct ItemRow { + id: ToolbarItemId, + row: adw::SwitchRow, + /// Kept so the refresh can write the switch with the handler blocked. + /// `ToolbarItemVisibilityChanged` is not a plain setter: it pins an + /// explicit visibility entry for section ids, so a refresh reporting the + /// resolved value back would add entries to a config nobody edited — and + /// the first refresh, which lifts every visible row off its built-in + /// `false`, would do it to every section on startup. + handler: glib::SignalHandlerId, + move_buttons: Option<(gtk::Button, gtk::Button)>, +} + +/// The widgets one [`ItemSection`] refreshes, kept so a single binding can +/// resolve the item config once for the whole page. +struct SectionWidgets { + order_group: Option, + built_in_order: Vec, + list: gtk::ListBox, + reset: Option, + rows: Vec, +} + +fn build_toolbar_visibility(sender: &ComponentSender) -> BuiltPage { + let built_in = ToolbarItemsConfig::default().resolved(); + let mut page = PageBuilder::new(sender, TabId::Ui); + + // Shown by the binding only while the config carries ids this build does + // not know. + let unknown_notice = note(""); + unknown_notice.set_visible(false); + page.group("Toolbar Visibility") + .custom(¬e( + "These are configured visibility defaults. Overlay customizations are stored separately as runtime preferences. Enabled items are shown; section toggles and mode overrides can still hide them.", + )) + .custom(&unknown_notice); + + let mut sections: Vec = Vec::new(); + for section in item_sections(&built_in) { + let list = gtk::ListBox::builder() + .selection_mode(gtk::SelectionMode::None) + .css_classes(["boxed-list"]) + .build(); + + let mut rows: Vec = Vec::new(); + for definition in §ion.definitions { + let id = definition.id; + let row = adw::SwitchRow::builder() + .title(definition.label) + .subtitle(format!( + "{} - built-in default: {}", + id.as_str(), + visibility_label(!built_in.is_hidden(id)) + )) + .build(); + let handler = { + let sender = page.sender(); + row.connect_active_notify(move |row| { + sender.input(Message::ToolbarItemVisibilityChanged(id, row.is_active())); + }) + }; + + let mut move_buttons = None; + if let Some(group) = section.order_group { + let up = move_button(&page.sender(), group, id, -1); + let down = move_button(&page.sender(), group, id, 1); + row.add_suffix(&up); + row.add_suffix(&down); + move_buttons = Some((up, down)); + } + + list.append(&row); + rows.push(ItemRow { + id, + row, + handler, + move_buttons, + }); + } + + page.group(§ion.title).custom(&list); + + let mut reset = None; + if let Some(group) = section.order_group { + let button = gtk::Button::builder() + .label("Restore built-in order") + .halign(gtk::Align::End) + .margin_top(6) + .build(); + { + let sender = page.sender(); + button.connect_clicked(move |_| { + sender.input(Message::ToolbarItemOrderReset(group)); + }); + } + page.custom(&button); + reset = Some(button); + } + + sections.push(SectionWidgets { + order_group: section.order_group, + built_in_order: section + .order_group + .map(|group| built_in.order.ordered_ids(group).to_vec()) + .unwrap_or_default(), + list, + reset, + rows, + }); + } + + // One binding for the whole page: resolving the item config allocates, + // and doing it per row would repeat that work a hundred times a refresh. + page.bind(move |app, _summary| { + let resolved = app.draft.ui_toolbar_items.resolved(); + + let unknown = resolved.unknown_hidden.len() + resolved.unknown_shown.len(); + let notice_text = if unknown > 0 { + format!("Preserving {unknown} unknown toolbar item id(s) from config.") + } else { + String::new() + }; + if unknown_notice.text() != notice_text { + unknown_notice.set_text(¬ice_text); + } + if unknown_notice.is_visible() != (unknown > 0) { + unknown_notice.set_visible(unknown > 0); + } + + for section in §ions { + for item in §ion.rows { + let visible = !resolved.is_hidden(item.id); + if item.row.is_active() != visible { + item.row.block_signal(&item.handler); + item.row.set_active(visible); + item.row.unblock_signal(&item.handler); + } + + let (Some(group), Some((up, down))) = (section.order_group, &item.move_buttons) + else { + continue; + }; + let index = resolved.order.index_of(group, item.id); + let length = resolved.order.ordered_ids(group).len(); + let can_move_up = index.is_some_and(|index| index > 0); + let can_move_down = index.is_some_and(|index| index + 1 < length); + if up.is_sensitive() != can_move_up { + up.set_sensitive(can_move_up); + } + if down.is_sensitive() != can_move_down { + down.set_sensitive(can_move_down); + } + } + + let Some(group) = section.order_group else { + continue; + }; + let ordered = resolved.order.ordered_ids(group); + let desired: Vec = ordered + .iter() + .copied() + .filter(|id| section.rows.iter().any(|item| item.id == *id)) + .collect(); + if current_row_order(section) != desired { + for item in §ion.rows { + section.list.remove(&item.row); + } + for id in &desired { + if let Some(item) = section.rows.iter().find(|item| item.id == *id) { + section.list.append(&item.row); + } + } + } + + if let Some(reset) = §ion.reset { + // The Iced view only offered the restore action once the + // order left the built-in one; insensitive keeps the button + // in place instead of making the section jump. + let restorable = ordered != section.built_in_order; + if reset.is_sensitive() != restorable { + reset.set_sensitive(restorable); + } + } + } + }); + + page.finish() +} + +/// Item rows grouped the way the Iced list read: by toolbar surface and +/// category, with each reorderable order group in a section of its own so it +/// can carry the move buttons and its restore action. +fn item_sections(built_in: &ResolvedToolbarItems) -> Vec { + let mut sections: Vec = Vec::new(); + for definition in toolbar_item_definitions() { + if definition.id == toolbar_item_ids::SIDE_GROUP_SETTINGS + || definition.id == toolbar_item_ids::TOP_CHROME_OVERFLOW + { + continue; + } + + let order_group = configurator_order_group(definition); + let title = match order_group { + Some((_, label)) => format!( + "{}: {} (reorderable)", + surface_label(definition.surface), + label + ), + None => format!( + "{}: {}", + surface_label(definition.surface), + category_label(definition.category) + ), + }; + + match sections.iter_mut().find(|section| section.title == title) { + Some(section) => section.definitions.push(definition), + None => sections.push(ItemSection { + title, + order_group: order_group.map(|(group, _)| group), + definitions: vec![definition], + }), + } + } + + // Reorderable sections start in the built-in order; the binding puts them + // in the configured order on the first refresh. + for section in &mut sections { + let Some(group) = section.order_group else { + continue; + }; + let order = built_in.order.ordered_ids(group); + section.definitions.sort_by_key(|definition| { + order + .iter() + .position(|id| *id == definition.id) + .unwrap_or(usize::MAX) + }); + } + + sections +} + +/// The order groups this page can reorder, with their section label. The +/// remaining groups keep the order the config resolves them in. +fn configurator_order_group( + definition: &ToolbarItemDefinition, +) -> Option<(ToolbarItemOrderGroup, &'static str)> { + match toolbar_item_order_group(definition)? { + ToolbarItemOrderGroup::TopTools => Some((ToolbarItemOrderGroup::TopTools, "Tools")), + ToolbarItemOrderGroup::TopControls => { + Some((ToolbarItemOrderGroup::TopControls, "Controls")) + } + ToolbarItemOrderGroup::SideSections => { + Some((ToolbarItemOrderGroup::SideSections, "Sections")) + } + _ => None, + } +} + +/// The ids currently laid out in a section, read back from the list itself so +/// the reorder pass needs no state of its own. +fn current_row_order(section: &SectionWidgets) -> Vec { + let mut order = Vec::with_capacity(section.rows.len()); + let mut child = section.list.first_child(); + while let Some(widget) = child { + if let Some(item) = section + .rows + .iter() + .find(|item| item.row.upcast_ref::() == &widget) + { + order.push(item.id); + } + child = widget.next_sibling(); + } + order +} + +fn move_button( + sender: &ComponentSender, + group: ToolbarItemOrderGroup, + id: ToolbarItemId, + delta: isize, +) -> gtk::Button { + let (icon, tooltip) = if delta < 0 { + ("go-up-symbolic", "Move up") + } else { + ("go-down-symbolic", "Move down") + }; + let button = gtk::Button::builder() + .icon_name(icon) + .tooltip_text(tooltip) + .valign(gtk::Align::Center) + .css_classes(["flat"]) + .build(); + let sender = sender.clone(); + button.connect_clicked(move |_| { + sender.input(Message::ToolbarItemMoveRequested(group, id, delta)); + }); + button +} + +fn visibility_label(visible: bool) -> &'static str { + if visible { "shown" } else { "hidden" } +} + +fn surface_label(surface: ToolbarItemSurface) -> &'static str { + match surface { + ToolbarItemSurface::Top => "Top toolbar", + ToolbarItemSurface::Side => "Side toolbar", + } +} + +fn category_label(category: ToolbarItemCategory) -> &'static str { + match category { + ToolbarItemCategory::Chrome => "Toolbar controls", + ToolbarItemCategory::Tool => "Tools", + ToolbarItemCategory::Utility => "Utilities", + ToolbarItemCategory::Group => "Sections", + ToolbarItemCategory::Action => "Actions", + ToolbarItemCategory::Page => "Pages", + ToolbarItemCategory::Board => "Boards", + ToolbarItemCategory::Setting => "Settings", + ToolbarItemCategory::Session => "Sessions", + ToolbarItemCategory::ToolOption => "Tool options", + } +} + +// ---- Status bar ------------------------------------------------------------ + +fn build_status_bar(sender: &ComponentSender) -> BuiltPage { + let (positions, position_labels) = options(StatusPositionOption::list(), |value| value.label()); + + let mut page = PageBuilder::new(sender, TabId::Ui); + + page.group("Status Bar") + .switch_row( + "Show status bar", + "", + |app| app.draft.ui_show_status_bar, + |value| Message::ToggleChanged(ToggleField::UiShowStatusBar, value), + ) + .switch_row( + "Clickable status bar segments", + "", + |app| app.draft.ui_status_bar_interactive, + |value| Message::ToggleChanged(ToggleField::UiStatusBarInteractive, value), + ); + + page.group("Contents") + .switch_row( + "Show active output", + "", + |app| app.draft.ui_active_output_badge, + |value| Message::ToggleChanged(ToggleField::UiActiveOutputBadge, value), + ) + .switch_row( + "Show selection dimensions", + "", + |app| app.draft.ui_show_status_selection_info, + |value| Message::ToggleChanged(ToggleField::UiShowStatusSelectionInfo, value), + ) + .switch_row( + "Show board label", + "", + |app| app.draft.ui_show_status_board_badge, + |value| Message::ToggleChanged(ToggleField::UiShowStatusBoardBadge, value), + ) + .switch_row( + "Show page counter", + "", + |app| app.draft.ui_show_status_page_badge, + |value| Message::ToggleChanged(ToggleField::UiShowStatusPageBadge, value), + ) + .switch_row( + "Show current color", + "", + |app| app.draft.ui_show_status_color, + |value| Message::ToggleChanged(ToggleField::UiShowStatusColor, value), + ) + .switch_row( + "Show active tool", + "", + |app| app.draft.ui_show_status_tool, + |value| Message::ToggleChanged(ToggleField::UiShowStatusTool, value), + ) + .switch_row( + "Show tool size", + "", + |app| app.draft.ui_show_status_size, + |value| Message::ToggleChanged(ToggleField::UiShowStatusSize, value), + ) + .switch_row( + "Show context indicators", + "", + |app| app.draft.ui_show_status_context_indicators, + |value| Message::ToggleChanged(ToggleField::UiShowStatusContextIndicators, value), + ) + .switch_row( + "Show toolbar hint while toolbars are hidden", + "", + |app| app.draft.ui_show_toolbar_hint, + |value| Message::ToggleChanged(ToggleField::UiShowToolbarHint, value), + ) + .switch_row( + "Show Help shortcut", + "", + |app| app.draft.ui_show_status_help, + |value| Message::ToggleChanged(ToggleField::UiShowStatusHelp, value), + ) + .switch_row( + "Show About and version", + "", + |app| app.draft.ui_show_status_about, + |value| Message::ToggleChanged(ToggleField::UiShowStatusAbout, value), + ); + + page.group("Additional Badges") + .switch_row( + "Show board/page badge", + "", + |app| app.draft.ui_show_floating_badge, + |value| Message::ToggleChanged(ToggleField::UiShowFloatingBadge, value), + ) + .switch_row( + "Also show badge with status bar", + "", + |app| app.draft.ui_show_page_badge_with_status_bar, + |value| Message::ToggleChanged(ToggleField::UiShowPageBadgeWithStatusBar, value), + ) + .switch_row( + "Show frozen badge", + "", + |app| app.draft.ui_show_frozen_badge, + |value| Message::ToggleChanged(ToggleField::UiShowFrozenBadge, value), + ) + .combo_row( + "Status bar position", + "", + positions, + position_labels, + |app| app.draft.ui_status_position, + Message::StatusPositionChanged, + ); + + page.group("Status Bar Style"); + color_row( + &mut page, + "Background (hex)", + ColorPickerId::StatusBarBg, + |app| quad_color(&app.draft.status_bar_bg_color.components), + ); + color_row( + &mut page, + "Text (hex)", + ColorPickerId::StatusBarText, + |app| quad_color(&app.draft.status_bar_text_color.components), + ); + page.entry_row( + "Font size", + |app| app.draft.status_font_size.clone(), + |value| Message::TextChanged(TextField::StatusFontSize, value), + ) + .entry_row( + "Padding", + |app| app.draft.status_padding.clone(), + |value| Message::TextChanged(TextField::StatusPadding, value), + ) + .entry_row( + "Dot radius", + |app| app.draft.status_dot_radius.clone(), + |value| Message::TextChanged(TextField::StatusDotRadius, value), + ); + + page.finish() +} + +// ---- Help overlay ---------------------------------------------------------- + +fn build_help_overlay(sender: &ComponentSender) -> BuiltPage { + let mut page = PageBuilder::new(sender, TabId::Ui); + + page.group("Help Overlay").switch_row( + "Filter sections by enabled features", + "", + |app| app.draft.help_context_filter, + |value| Message::ToggleChanged(ToggleField::UiHelpOverlayContextFilter, value), + ); + + page.group("Help Overlay Style"); + color_row( + &mut page, + "Background (hex)", + ColorPickerId::HelpBg, + |app| quad_color(&app.draft.help_bg_color.components), + ); + color_row( + &mut page, + "Border (hex)", + ColorPickerId::HelpBorder, + |app| quad_color(&app.draft.help_border_color.components), + ); + color_row(&mut page, "Text (hex)", ColorPickerId::HelpText, |app| { + quad_color(&app.draft.help_text_color.components) + }); + page.entry_row( + "Font family", + |app| app.draft.help_font_family.clone(), + |value| Message::TextChanged(TextField::HelpFontFamily, value), + ) + .entry_row( + "Font size", + |app| app.draft.help_font_size.clone(), + |value| Message::TextChanged(TextField::HelpFontSize, value), + ) + .entry_row( + "Line height", + |app| app.draft.help_line_height.clone(), + |value| Message::TextChanged(TextField::HelpLineHeight, value), + ) + .entry_row( + "Padding", + |app| app.draft.help_padding.clone(), + |value| Message::TextChanged(TextField::HelpPadding, value), + ) + .entry_row( + "Border width", + |app| app.draft.help_border_width.clone(), + |value| Message::TextChanged(TextField::HelpBorderWidth, value), + ); + + page.finish() +} + +// ---- Click highlight ------------------------------------------------------- + +fn build_click_highlight(sender: &ComponentSender) -> BuiltPage { + let mut page = PageBuilder::new(sender, TabId::Ui); + + page.group("Click Highlight") + .switch_row( + "Enable click highlight", + "", + |app| app.draft.click_highlight_enabled, + |value| Message::ToggleChanged(ToggleField::UiClickHighlightEnabled, value), + ) + .switch_row( + "Show ring while highlight tool is active", + "", + |app| app.draft.click_highlight_show_on_highlight_tool, + |value| Message::ToggleChanged(ToggleField::UiClickHighlightShowOnHighlightTool, value), + ) + .switch_row( + "Link highlight color to current pen", + "", + |app| app.draft.click_highlight_use_pen_color, + |value| Message::ToggleChanged(ToggleField::UiClickHighlightUsePenColor, value), + ) + .switch_row( + "Force on when entering light mode", + "", + |app| app.draft.click_highlight_force_in_light_mode, + |value| Message::ToggleChanged(ToggleField::UiClickHighlightForceInLightMode, value), + ); + + page.group("Ring") + .entry_row_validated( + "Radius", + |app| app.draft.click_highlight_radius.clone(), + |value| Message::TextChanged(TextField::HighlightRadius, value), + |app| validate_f64_range(&app.draft.click_highlight_radius, 16.0, 160.0), + ) + .entry_row_validated( + "Outline thickness", + |app| app.draft.click_highlight_outline_thickness.clone(), + |value| Message::TextChanged(TextField::HighlightOutlineThickness, value), + |app| validate_f64_range(&app.draft.click_highlight_outline_thickness, 1.0, 12.0), + ) + .entry_row_validated( + "Duration (ms)", + |app| app.draft.click_highlight_duration_ms.clone(), + |value| Message::TextChanged(TextField::HighlightDurationMs, value), + |app| validate_u32_range(&app.draft.click_highlight_duration_ms, 150, 1500), + ); + + page.group("Colors"); + color_row( + &mut page, + "Fill (hex)", + ColorPickerId::HighlightFill, + |app| quad_color(&app.draft.click_highlight_fill_color.components), + ); + color_row( + &mut page, + "Outline (hex)", + ColorPickerId::HighlightOutline, + |app| quad_color(&app.draft.click_highlight_outline_color.components), + ); + + page.finish() +} + +// ---- Input HUD ------------------------------------------------------------- + +fn build_input_hud(sender: &ComponentSender) -> BuiltPage { + let (modes, mode_labels) = options(InputHudModeOption::list(), |value| value.label()); + let (positions, position_labels) = + options(InputHudPositionOption::list(), |value| value.label()); + + let mut page = PageBuilder::new(sender, TabId::Ui); + + page.group("Input HUD") + .custom(¬e( + "Show a live row of keystroke and click chips for demos and screencasts.", + )) + .switch_row( + "Enable input HUD", + "", + |app| app.draft.input_hud_enabled, + |value| Message::ToggleChanged(ToggleField::UiInputHudEnabled, value), + ) + .combo_row( + "Input source", + "\"Overlay only\" shows what Wayscriber itself receives. \"System-wide\" also shows input that goes to other apps; it needs a build with the input-monitor feature and read access to /dev/input (usually `input` group membership), and it sees every keystroke on the seat - including passwords typed elsewhere.", + modes, + mode_labels, + |app| app.draft.input_hud_mode, + Message::InputHudModeChanged, + ) + .combo_row( + "Screen position", + "", + positions, + position_labels, + |app| app.draft.input_hud_position, + Message::InputHudPositionChanged, + ) + .switch_row( + "Show mouse buttons and scroll", + "", + |app| app.draft.input_hud_show_mouse, + |value| Message::ToggleChanged(ToggleField::UiInputHudShowMouse, value), + ) + .switch_row( + "Show bare modifier taps", + "", + |app| app.draft.input_hud_show_bare_modifiers, + |value| Message::ToggleChanged(ToggleField::UiInputHudShowBareModifiers, value), + ) + .switch_row( + "Combine repeats into a counter", + "", + |app| app.draft.input_hud_combine_repeats, + |value| Message::ToggleChanged(ToggleField::UiInputHudCombineRepeats, value), + ); + + page.group("Chips") + .entry_row_validated( + "Hold (ms)", + |app| app.draft.input_hud_display_ms.clone(), + |value| Message::TextChanged(TextField::InputHudDisplayMs, value), + |app| validate_u32_range(&app.draft.input_hud_display_ms, 200, 30_000), + ) + .entry_row_validated( + "Fade (ms)", + |app| app.draft.input_hud_fade_ms.clone(), + |value| Message::TextChanged(TextField::InputHudFadeMs, value), + |app| validate_u32_range(&app.draft.input_hud_fade_ms, 0, 5_000), + ) + .entry_row_validated( + "Max chips", + |app| app.draft.input_hud_max_entries.clone(), + |value| Message::TextChanged(TextField::InputHudMaxEntries, value), + |app| validate_u32_range(&app.draft.input_hud_max_entries, 1, 16), + ) + .entry_row_validated( + "Font size", + |app| app.draft.input_hud_font_size.clone(), + |value| Message::TextChanged(TextField::InputHudFontSize, value), + |app| validate_f64_range(&app.draft.input_hud_font_size, 6.0, 72.0), + ); + + page.finish() +} + +// ---- Presenter mode -------------------------------------------------------- + +fn build_presenter_mode(sender: &ComponentSender) -> BuiltPage { + let (toolbar_modes, toolbar_mode_labels) = + options(PresenterToolbarModeOption::list(), |value| value.label()); + let (behaviors, behavior_labels) = + options(PresenterToolBehaviorOption::list(), |value| value.label()); + + let mut page = PageBuilder::new(sender, TabId::Ui); + + page.group("Presenter Mode") + .custom(¬e("Customize what presenter mode changes when toggled.")) + .switch_row( + "Hide status bar", + "", + |app| app.draft.presenter_hide_status_bar, + |value| Message::ToggleChanged(ToggleField::PresenterHideStatusBar, value), + ) + .switch_row( + "Hide toolbars", + "", + |app| app.draft.presenter_hide_toolbars, + |value| Message::ToggleChanged(ToggleField::PresenterHideToolbars, value), + ) + .combo_row( + "Top toolbar while presenting", + "", + toolbar_modes, + toolbar_mode_labels, + |app| app.draft.presenter_toolbar_mode, + Message::PresenterToolbarModeChanged, + ) + .switch_row( + "Hide tool preview", + "", + |app| app.draft.presenter_hide_tool_preview, + |value| Message::ToggleChanged(ToggleField::PresenterHideToolPreview, value), + ) + .switch_row( + "Close help overlay on entry", + "", + |app| app.draft.presenter_close_help_overlay, + |value| Message::ToggleChanged(ToggleField::PresenterCloseHelpOverlay, value), + ) + .switch_row( + "Force click highlights on", + "", + |app| app.draft.presenter_enable_click_highlight, + |value| Message::ToggleChanged(ToggleField::PresenterEnableClickHighlight, value), + ) + .switch_row( + "Force input HUD on", + "", + |app| app.draft.presenter_enable_input_hud, + |value| Message::ToggleChanged(ToggleField::PresenterEnableInputHud, value), + ) + .combo_row( + "Tool behavior", + "", + behaviors, + behavior_labels, + |app| app.draft.presenter_tool_behavior, + Message::PresenterToolBehaviorChanged, + ) + .switch_row( + "Show enter/exit toast", + "", + |app| app.draft.presenter_show_toast, + |value| Message::ToggleChanged(ToggleField::PresenterShowToast, value), + ); + + page.finish() +} + +// ---- Shared helpers -------------------------------------------------------- + +/// A combo row's values with their labels, in one call. +fn options(values: Vec, label: impl Fn(&O) -> &'static str) -> (Vec, Vec) { + let labels = values + .iter() + .map(|value| label(value).to_string()) + .collect(); + (values, labels) +} + +/// Explanatory text a row cannot carry: `AdwEntryRow` has no subtitle, and a +/// preferences group renders a plain widget under its rows. +fn note(text: &str) -> gtk::Label { + gtk::Label::builder() + .label(text) + .wrap(true) + .xalign(0.0) + .margin_top(6) + .css_classes(["caption", "dim-label"]) + .build() +} + +fn quad_color(components: &[String; 4]) -> ResolvedColor { + let values = parse_quad_values(components); + Some((values[0], values[1], values[2], values[3])) +} + +/// Error text for a decimal field constrained to `min..=max`, `None` while +/// the input is acceptable. +fn validate_f64_range(value: &str, min: f64, max: f64) -> Option { + match value.trim().parse::() { + Ok(parsed) if parsed.is_finite() && (min..=max).contains(&parsed) => None, + _ => Some(format!("Enter a number between {min} and {max}.")), + } +} diff --git a/configurator/src/app/scroll.rs b/configurator/src/app/scroll.rs deleted file mode 100644 index 5d3f9f36..00000000 --- a/configurator/src/app/scroll.rs +++ /dev/null @@ -1,196 +0,0 @@ -use iced::Task; -use iced::keyboard::{self, Key, key}; -use iced::widget::operation::{self, AbsoluteOffset}; - -use crate::messages::Message; - -pub(crate) const CONTENT_SCROLL_ID: &str = "configurator-content-scroll"; - -const LINE_SCROLL_DELTA_Y: f32 = 64.0; -const PAGE_SCROLL_DELTA_Y: f32 = 360.0; -const EDGE_SCROLL_DELTA_Y: f32 = 1_000_000.0; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum ContentScrollAction { - Top, - Bottom, - LineUp, - LineDown, - PageUp, - PageDown, -} - -impl ContentScrollAction { - pub(crate) fn can_scroll_when_captured(self) -> bool { - matches!(self, ContentScrollAction::Top | ContentScrollAction::Bottom) - } - - pub(crate) fn task(self) -> Task { - match self { - ContentScrollAction::Top => scroll_by_y(-EDGE_SCROLL_DELTA_Y), - ContentScrollAction::Bottom => scroll_by_y(EDGE_SCROLL_DELTA_Y), - ContentScrollAction::LineUp => scroll_by_y(-LINE_SCROLL_DELTA_Y), - ContentScrollAction::LineDown => scroll_by_y(LINE_SCROLL_DELTA_Y), - ContentScrollAction::PageUp => scroll_by_y(-PAGE_SCROLL_DELTA_Y), - ContentScrollAction::PageDown => scroll_by_y(PAGE_SCROLL_DELTA_Y), - } - } -} - -pub(crate) fn content_scroll_action_for_event( - event: &keyboard::Event, -) -> Option { - let keyboard::Event::KeyPressed { - key, - physical_key, - modifiers, - .. - } = event - else { - return None; - }; - - if !modifiers.is_empty() { - return None; - } - - content_scroll_action_for_key(key.as_ref()) - .or_else(|| content_scroll_action_for_physical_key(*physical_key)) -} - -fn content_scroll_action_for_key(key: Key<&str>) -> Option { - match key { - Key::Named(key::Named::Home) => Some(ContentScrollAction::Top), - Key::Named(key::Named::End) => Some(ContentScrollAction::Bottom), - Key::Named(key::Named::ArrowUp) => Some(ContentScrollAction::LineUp), - Key::Named(key::Named::ArrowDown) => Some(ContentScrollAction::LineDown), - Key::Named(key::Named::PageUp) => Some(ContentScrollAction::PageUp), - Key::Named(key::Named::PageDown) => Some(ContentScrollAction::PageDown), - _ => None, - } -} - -fn content_scroll_action_for_physical_key( - physical_key: key::Physical, -) -> Option { - match physical_key { - key::Physical::Code(key::Code::Home) => Some(ContentScrollAction::Top), - key::Physical::Code(key::Code::End) => Some(ContentScrollAction::Bottom), - key::Physical::Code(key::Code::ArrowUp) => Some(ContentScrollAction::LineUp), - key::Physical::Code(key::Code::ArrowDown) => Some(ContentScrollAction::LineDown), - key::Physical::Code(key::Code::PageUp) => Some(ContentScrollAction::PageUp), - key::Physical::Code(key::Code::PageDown) => Some(ContentScrollAction::PageDown), - _ => None, - } -} - -fn scroll_by_y(y: f32) -> Task { - operation::scroll_by(CONTENT_SCROLL_ID, AbsoluteOffset { x: 0.0, y }) -} - -#[cfg(test)] -mod tests { - use super::*; - use iced::keyboard::{Location, Modifiers, key}; - - fn key_press(key: Key) -> keyboard::Event { - keyboard::Event::KeyPressed { - key: key.clone(), - modified_key: key, - physical_key: key::Physical::Unidentified(key::NativeCode::Unidentified), - location: Location::Standard, - modifiers: Modifiers::empty(), - text: None, - repeat: false, - } - } - - fn physical_key_press(physical_key: key::Physical) -> keyboard::Event { - keyboard::Event::KeyPressed { - key: Key::Unidentified, - modified_key: Key::Unidentified, - physical_key, - location: Location::Standard, - modifiers: Modifiers::empty(), - text: None, - repeat: false, - } - } - - #[test] - fn navigation_keys_map_to_content_scroll_actions() { - let cases = [ - (Key::Named(key::Named::Home), ContentScrollAction::Top), - (Key::Named(key::Named::End), ContentScrollAction::Bottom), - (Key::Named(key::Named::ArrowUp), ContentScrollAction::LineUp), - ( - Key::Named(key::Named::ArrowDown), - ContentScrollAction::LineDown, - ), - (Key::Named(key::Named::PageUp), ContentScrollAction::PageUp), - ( - Key::Named(key::Named::PageDown), - ContentScrollAction::PageDown, - ), - ]; - - for (key, expected) in cases { - assert_eq!( - content_scroll_action_for_event(&key_press(key)), - Some(expected) - ); - } - } - - #[test] - fn modified_navigation_keys_do_not_scroll_content() { - let event = keyboard::Event::KeyPressed { - key: Key::Named(key::Named::Home), - modified_key: Key::Named(key::Named::Home), - physical_key: key::Physical::Code(key::Code::Home), - location: Location::Standard, - modifiers: Modifiers::CTRL, - text: None, - repeat: false, - }; - - assert_eq!(content_scroll_action_for_event(&event), None); - } - - #[test] - fn physical_navigation_keys_map_to_content_scroll_actions() { - let cases = [ - ( - key::Physical::Code(key::Code::Home), - ContentScrollAction::Top, - ), - ( - key::Physical::Code(key::Code::End), - ContentScrollAction::Bottom, - ), - ( - key::Physical::Code(key::Code::ArrowUp), - ContentScrollAction::LineUp, - ), - ( - key::Physical::Code(key::Code::ArrowDown), - ContentScrollAction::LineDown, - ), - ( - key::Physical::Code(key::Code::PageUp), - ContentScrollAction::PageUp, - ), - ( - key::Physical::Code(key::Code::PageDown), - ContentScrollAction::PageDown, - ), - ]; - - for (physical_key, expected) in cases { - assert_eq!( - content_scroll_action_for_event(&physical_key_press(physical_key)), - Some(expected) - ); - } - } -} diff --git a/configurator/src/app/search/mod.rs b/configurator/src/app/search/mod.rs index 13f50614..621a2956 100644 --- a/configurator/src/app/search/mod.rs +++ b/configurator/src/app/search/mod.rs @@ -4,19 +4,13 @@ mod terms; mod tests; mod types; -use iced::keyboard::{self, Key, key}; -use iced::{Task, event}; - -use crate::messages::Message; use crate::models::{SearchQuery, TabId}; -use super::scroll; +use super::effects::Effect; use super::state::ConfiguratorApp; pub(crate) use types::{AppSearchSummary, SearchArea, TabSearchSummary}; -pub(crate) const SEARCH_INPUT_ID: &str = "configurator-search-input"; - impl ConfiguratorApp { pub(crate) fn search_summary(&self) -> AppSearchSummary { summary::build_search_summary(self) @@ -63,93 +57,40 @@ impl ConfiguratorApp { } } - pub(super) fn handle_search_changed(&mut self, value: String) -> Task { - self.search_input_focus_hint = true; + pub(super) fn handle_search_changed(&mut self, value: String) -> Vec { self.search_query = SearchQuery::new(value); self.align_active_tabs_for_search(); - Task::none() + Vec::new() } - pub(super) fn handle_search_cleared(&mut self) -> Task { + pub(super) fn handle_search_cleared(&mut self) -> Vec { self.search_query = SearchQuery::default(); - Task::none() + Vec::new() } - pub(super) fn handle_search_focus_requested(&mut self) -> Task { - self.search_input_focus_hint = true; - iced::widget::operation::focus(SEARCH_INPUT_ID) + /// Asks the shell to put the caret in the search box once. + /// + /// The request is a serial rather than a task: the model cannot reach a + /// widget, and the shell honors each new serial exactly once. + pub(super) fn handle_search_focus_requested(&mut self) -> Vec { + self.search_focus_serial = self.search_focus_serial.saturating_add(1); + Vec::new() } - pub(super) fn handle_startup_search_focus_config_fallback(&mut self) -> Task { - if !self.startup_search_focus_pending { - return Task::none(); - } - + /// A click, tap, or Tab press observed before the initial config load + /// finished: the deferred startup focus offer is answered by the user's + /// own navigation and must not fire later. + pub(super) fn handle_startup_interaction_observed(&mut self) -> Vec { self.startup_search_focus_pending = false; - self.handle_search_focus_requested() - } - - pub(super) fn handle_search_focus_observed(&mut self, is_focused: bool) -> Task { - self.search_input_focus_hint = is_focused; - Task::none() - } - - pub(super) fn handle_pointer_pressed(&mut self) -> Task { - self.cancel_startup_search_focus(); - self.observe_search_focus() + Vec::new() } - pub(super) fn handle_keyboard_event( - &mut self, - event: keyboard::Event, - status: event::Status, - ) -> Task { - let keyboard::Event::KeyPressed { key, modifiers, .. } = &event else { - return Task::none(); - }; - - match key.as_ref() { - Key::Character("f") | Key::Character("F") if modifiers.command() => { - self.handle_search_focus_requested() - } - Key::Named(key::Named::Escape) if self.search_query.has_raw_input() => { - let should_refocus_search = self.search_input_focus_hint; - self.search_query = SearchQuery::default(); - if should_refocus_search { - self.handle_search_focus_requested() - } else { - Task::none() - } - } - Key::Named(key::Named::Tab) => { - self.cancel_startup_search_focus(); - self.observe_search_focus() - } - _ => content_scroll_action_for_status(&event, status, self.search_input_focus_hint) - .map_or_else(Task::none, scroll::ContentScrollAction::task), + pub(super) fn handle_startup_search_focus_config_fallback(&mut self) -> Vec { + if !self.startup_search_focus_pending { + return Vec::new(); } - } - fn cancel_startup_search_focus(&mut self) { self.startup_search_focus_pending = false; - } - - fn observe_search_focus(&self) -> Task { - iced::widget::operation::is_focused(SEARCH_INPUT_ID).map(Message::SearchFocusObserved) - } -} - -fn content_scroll_action_for_status( - event: &keyboard::Event, - status: event::Status, - allow_captured_edges: bool, -) -> Option { - let action = scroll::content_scroll_action_for_event(event)?; - if status == event::Status::Ignored - || (allow_captured_edges && action.can_scroll_when_captured()) - { - Some(action) - } else { - None + self.handle_search_focus_requested() } } diff --git a/configurator/src/app/search/tests.rs b/configurator/src/app/search/tests.rs index 71da9d01..a20e54c3 100644 --- a/configurator/src/app/search/tests.rs +++ b/configurator/src/app/search/tests.rs @@ -1,6 +1,11 @@ +//! Search model coverage. +//! +//! Keyboard, focus-observation, and content-scroll checks are gone with the +//! Iced view layer: GTK does that natively. The view-label parity checks — +//! that every control label a page shows is indexed by the search terms — +//! return with the GTK page registry, once every page is ported. + use super::*; -use iced::event::Status; -use iced::keyboard::{self, Key, Location, Modifiers, key}; use std::path::PathBuf; use crate::models::session::SessionArtifactSummary; @@ -11,7 +16,7 @@ use wayscriber::config::toolbar_item_ids as ids; #[test] fn active_search_tab_click_corrects_keybindings_nested_tab() { - let (mut app, _task) = ConfiguratorApp::new_app(); + let (mut app, _effects) = ConfiguratorApp::new_app(); app.search_query = SearchQuery::new("pdf"); app.active_tab = TabId::Keybindings; app.active_keybindings_tab = KeybindingsTabId::General; @@ -23,7 +28,7 @@ fn active_search_tab_click_corrects_keybindings_nested_tab() { #[test] fn active_search_tab_click_corrects_ui_nested_tab() { - let (mut app, _task) = ConfiguratorApp::new_app(); + let (mut app, _effects) = ConfiguratorApp::new_app(); app.search_query = SearchQuery::new("presenter"); app.active_tab = TabId::Ui; app.active_ui_tab = UiTabId::Toolbar; @@ -35,19 +40,18 @@ fn active_search_tab_click_corrects_ui_nested_tab() { #[test] fn direct_tab_title_match_exposes_concrete_tab_content() { - let (mut app, _task) = ConfiguratorApp::new_app(); + let (mut app, _effects) = ConfiguratorApp::new_app(); app.search_query = SearchQuery::new("keybindings"); let summary = app.search_summary(); let tab = summary.tab(TabId::Keybindings).expect("keybindings match"); assert!(tab.show_all()); - assert_eq!(summary.total_matches(), 1); } #[test] fn alias_match_exposes_keybindings_without_empty_tab() { - let (mut app, _task) = ConfiguratorApp::new_app(); + let (mut app, _effects) = ConfiguratorApp::new_app(); app.search_query = SearchQuery::new("shortcut"); let summary = app.search_summary(); @@ -58,7 +62,7 @@ fn alias_match_exposes_keybindings_without_empty_tab() { #[test] fn pdf_matches_capture_and_capture_view_keybindings() { - let (mut app, _task) = ConfiguratorApp::new_app(); + let (mut app, _effects) = ConfiguratorApp::new_app(); app.search_query = SearchQuery::new("pdf"); let summary = app.search_summary(); @@ -71,7 +75,7 @@ fn pdf_matches_capture_and_capture_view_keybindings() { #[test] fn direct_nested_keybinding_title_shows_that_tabs_rows() { - let (mut app, _task) = ConfiguratorApp::new_app(); + let (mut app, _effects) = ConfiguratorApp::new_app(); app.search_query = SearchQuery::new("capture view"); let summary = app.search_summary(); @@ -83,7 +87,7 @@ fn direct_nested_keybinding_title_shows_that_tabs_rows() { #[test] fn parent_scoped_keybinding_title_shows_nested_tab() { - let (mut app, _task) = ConfiguratorApp::new_app(); + let (mut app, _effects) = ConfiguratorApp::new_app(); app.search_query = SearchQuery::new("keybindings capture view"); let summary = app.search_summary(); @@ -96,7 +100,7 @@ fn parent_scoped_keybinding_title_shows_nested_tab() { #[test] fn field_level_terms_do_not_force_whole_tab_visible() { - let (mut app, _task) = ConfiguratorApp::new_app(); + let (mut app, _effects) = ConfiguratorApp::new_app(); app.search_query = SearchQuery::new("font"); let summary = app.search_summary(); @@ -109,7 +113,7 @@ fn field_level_terms_do_not_force_whole_tab_visible() { #[test] fn exact_drawing_default_labels_match_defaults_section() { for query in ["font size pt", "eraser size px", "enable text background"] { - let (mut app, _task) = ConfiguratorApp::new_app(); + let (mut app, _effects) = ConfiguratorApp::new_app(); app.search_query = SearchQuery::new(query); let summary = app.search_summary(); @@ -132,7 +136,7 @@ fn exact_drawing_color_and_font_labels_match_their_sections() { ]; for (query, expected_area) in cases { - let (mut app, _task) = ConfiguratorApp::new_app(); + let (mut app, _effects) = ConfiguratorApp::new_app(); app.search_query = SearchQuery::new(query); let summary = app.search_summary(); @@ -154,7 +158,7 @@ fn exact_performance_rendering_labels_match_rendering_section() { "performance buffer count", "render performance buffer count", ] { - let (mut app, _task) = ConfiguratorApp::new_app(); + let (mut app, _effects) = ConfiguratorApp::new_app(); app.search_query = SearchQuery::new(query); let summary = app.search_summary(); @@ -221,7 +225,7 @@ fn exact_static_section_labels_match_their_sections() { ]; for (query, expected_tab, expected_area) in cases { - let (mut app, _task) = ConfiguratorApp::new_app(); + let (mut app, _effects) = ConfiguratorApp::new_app(); app.search_query = SearchQuery::new(query); let summary = app.search_summary(); @@ -242,7 +246,7 @@ fn exact_tablet_labels_match_tablet_section() { "min thickness", "pressure thickness scale step", ] { - let (mut app, _task) = ConfiguratorApp::new_app(); + let (mut app, _effects) = ConfiguratorApp::new_app(); app.search_query = SearchQuery::new(query); let summary = app.search_summary(); @@ -257,7 +261,7 @@ fn exact_tablet_labels_match_tablet_section() { #[test] fn ui_nested_alias_matches_concrete_nested_tab() { - let (mut app, _task) = ConfiguratorApp::new_app(); + let (mut app, _effects) = ConfiguratorApp::new_app(); app.search_query = SearchQuery::new("toolbar"); let summary = app.search_summary(); @@ -281,7 +285,7 @@ fn parent_scoped_ui_queries_match_concrete_nested_tabs() { ]; for (query, expected_tabs) in cases { - let (mut app, _task) = ConfiguratorApp::new_app(); + let (mut app, _effects) = ConfiguratorApp::new_app(); app.search_query = SearchQuery::new(query); let summary = app.search_summary(); @@ -312,7 +316,7 @@ fn ui_nested_visible_control_labels_match_concrete_nested_tabs() { ]; for (query, expected_tab) in cases { - let (mut app, _task) = ConfiguratorApp::new_app(); + let (mut app, _effects) = ConfiguratorApp::new_app(); app.search_query = SearchQuery::new(query); let summary = app.search_summary(); @@ -333,7 +337,7 @@ fn ui_nested_visible_control_labels_match_concrete_nested_tabs() { #[test] fn zoom_chip_control_labels_expose_the_toolbar_settings_tab() { for query in ["zoom chip", "show zoom chip"] { - let (mut app, _task) = ConfiguratorApp::new_app(); + let (mut app, _effects) = ConfiguratorApp::new_app(); app.search_query = SearchQuery::new(query); let summary = app.search_summary(); @@ -349,7 +353,7 @@ fn zoom_chip_control_labels_expose_the_toolbar_settings_tab() { #[test] fn dynamic_matches_preserve_original_indices() { - let (mut app, _task) = ConfiguratorApp::new_app(); + let (mut app, _effects) = ConfiguratorApp::new_app(); app.draft.boards.items[1].name = "Meeting board".to_string(); app.search_query = SearchQuery::new("meeting"); @@ -407,7 +411,7 @@ fn scoped_tab_section_queries_match_target_sections() { ]; for (query, expected_tab, expected_area, hidden_areas) in cases { - let (mut app, _task) = ConfiguratorApp::new_app(); + let (mut app, _effects) = ConfiguratorApp::new_app(); app.search_query = SearchQuery::new(query); let summary = app.search_summary(); @@ -430,172 +434,39 @@ fn scoped_tab_section_queries_match_target_sections() { } } -#[test] -fn inactive_token_search_preserves_raw_input_text() { - let (mut app, _task) = ConfiguratorApp::new_app(); - app.search_query = SearchQuery::new("/"); - - let summary = app.search_summary(); - - assert!(!summary.is_active()); - assert!(summary.has_raw_input()); - assert_eq!(summary.raw_query(), "/"); - assert_eq!(summary.total_matches(), 0); -} - -#[test] -fn escape_clears_inactive_raw_search_text() { - let (mut app, _task) = ConfiguratorApp::new_app(); - app.search_query = SearchQuery::new("/"); - - let _ = app.handle_keyboard_event( - keyboard::Event::KeyPressed { - key: Key::Named(key::Named::Escape), - modified_key: Key::Named(key::Named::Escape), - physical_key: key::Physical::Code(key::Code::Escape), - location: Location::Standard, - modifiers: Modifiers::empty(), - text: None, - repeat: false, - }, - Status::Captured, - ); - - assert_eq!(app.search_query.raw(), ""); -} - -#[test] -fn escape_refocus_hint_is_cleared_after_pointer_press() { - let (mut app, _task) = ConfiguratorApp::new_app(); - let _ = app.handle_search_changed("preset".to_string()); - assert!(app.search_input_focus_hint); - - let _ = app.handle_pointer_pressed(); - assert!(app.search_input_focus_hint); - - let _ = app.handle_search_focus_observed(false); - assert!(!app.search_input_focus_hint); - - let _ = app.handle_keyboard_event( - keyboard::Event::KeyPressed { - key: Key::Named(key::Named::Escape), - modified_key: Key::Named(key::Named::Escape), - physical_key: key::Physical::Code(key::Code::Escape), - location: Location::Standard, - modifiers: Modifiers::empty(), - text: None, - repeat: false, - }, - Status::Captured, - ); - - assert_eq!(app.search_query.raw(), ""); - assert!(!app.search_input_focus_hint); -} - #[test] fn startup_config_fallback_consumes_startup_focus_pending_state() { - let (mut app, _task) = ConfiguratorApp::new_app(); + let (mut app, _effects) = ConfiguratorApp::new_app(); let _ = app.handle_startup_search_focus_config_fallback(); - assert!(app.search_input_focus_hint); + assert_eq!(app.search_focus_serial, 1); assert!(!app.startup_search_focus_pending); -} - -#[test] -fn pointer_press_cancels_pending_startup_focus() { - let (mut app, _task) = ConfiguratorApp::new_app(); - - let _ = app.handle_pointer_pressed(); - assert!(!app.startup_search_focus_pending); -} - -#[test] -fn startup_config_fallback_does_not_focus_after_pointer_press() { - let (mut app, _task) = ConfiguratorApp::new_app(); - app.search_input_focus_hint = false; - - let _ = app.handle_pointer_pressed(); + // Answered once: a later fallback is not a second launch. let _ = app.handle_startup_search_focus_config_fallback(); - - assert!(!app.search_input_focus_hint); - assert!(!app.startup_search_focus_pending); + assert_eq!(app.search_focus_serial, 1); } +/// A click, tap, or Tab press before the initial config load lands answers +/// the startup focus offer: the deferred fallback must not steal focus from +/// wherever the user already went. #[test] -fn tab_key_cancels_pending_startup_focus() { - let (mut app, _task) = ConfiguratorApp::new_app(); +fn startup_interaction_cancels_the_deferred_search_focus() { + let (mut app, _effects) = ConfiguratorApp::new_app(); + assert!(app.startup_search_focus_pending); - let _ = app.handle_keyboard_event( - keyboard::Event::KeyPressed { - key: Key::Named(key::Named::Tab), - modified_key: Key::Named(key::Named::Tab), - physical_key: key::Physical::Code(key::Code::Tab), - location: Location::Standard, - modifiers: Modifiers::empty(), - text: None, - repeat: false, - }, - Status::Captured, - ); + let _ = app.handle_startup_interaction_observed(); + let _ = app.handle_startup_search_focus_config_fallback(); assert!(!app.startup_search_focus_pending); -} - -#[test] -fn observed_search_focus_allows_captured_home_end() { - let (mut app, _task) = ConfiguratorApp::new_app(); - app.search_input_focus_hint = false; - - let _ = app.handle_search_focus_observed(true); - - assert!(app.search_input_focus_hint); - for key in [key::Named::Home, key::Named::End] { - let event = keyboard::Event::KeyPressed { - key: Key::Named(key), - modified_key: Key::Named(key), - physical_key: key::Physical::Unidentified(key::NativeCode::Unidentified), - location: Location::Standard, - modifiers: Modifiers::empty(), - text: None, - repeat: false, - }; - - assert!( - content_scroll_action_for_status(&event, Status::Captured, app.search_input_focus_hint) - .is_some() - ); - } -} - -#[test] -fn captured_home_end_scroll_only_when_search_focus_hint_is_active() { - for key in [key::Named::Home, key::Named::End] { - let event = keyboard::Event::KeyPressed { - key: Key::Named(key), - modified_key: Key::Named(key), - physical_key: key::Physical::Unidentified(key::NativeCode::Unidentified), - location: Location::Standard, - modifiers: Modifiers::empty(), - text: None, - repeat: false, - }; - - assert_eq!( - content_scroll_action_for_status(&event, Status::Captured, false), - None - ); - assert!(content_scroll_action_for_status(&event, Status::Captured, true).is_some()); - assert!(content_scroll_action_for_status(&event, Status::Ignored, false).is_some()); - } + assert_eq!(app.search_focus_serial, 0, "no focus grab may fire"); } #[test] fn board_item_static_labels_match_board_rows() { for query in ["display name", "board id", "override default pen color"] { - let (mut app, _task) = ConfiguratorApp::new_app(); + let (mut app, _effects) = ConfiguratorApp::new_app(); app.search_query = SearchQuery::new(query); let summary = app.search_summary(); @@ -612,7 +483,7 @@ fn board_item_static_labels_match_board_rows() { #[test] fn render_profile_matches_preserve_original_indices() { - let (mut app, _task) = ConfiguratorApp::new_app(); + let (mut app, _effects) = ConfiguratorApp::new_app(); let profile = app.draft.render_profiles.new_profile(); app.draft.render_profiles.profiles.push(profile); app.draft.render_profiles.profiles[0].name = "Night colors".to_string(); @@ -628,7 +499,7 @@ fn render_profile_matches_preserve_original_indices() { #[test] fn render_profile_label_match_keeps_profile_controls_visible() { - let (mut app, _task) = ConfiguratorApp::new_app(); + let (mut app, _effects) = ConfiguratorApp::new_app(); let profile = app.draft.render_profiles.new_profile(); app.draft.render_profiles.profiles.push(profile); app.search_query = SearchQuery::new("render profile 1"); @@ -645,7 +516,7 @@ fn render_profile_label_match_keeps_profile_controls_visible() { #[test] fn session_catalog_action_match_keeps_catalog_items_visible() { - let (mut app, _task) = ConfiguratorApp::new_app(); + let (mut app, _effects) = ConfiguratorApp::new_app(); app.session_catalog.replace_items(vec![catalog_item("s-1")]); app.search_query = SearchQuery::new("rename"); @@ -658,7 +529,7 @@ fn session_catalog_action_match_keeps_catalog_items_visible() { #[test] fn preset_slot_search_indexes_visible_slot_body_labels() { - let (mut app, _task) = ConfiguratorApp::new_app(); + let (mut app, _effects) = ConfiguratorApp::new_app(); app.draft.presets.slot_mut(1).expect("slot 1").enabled = true; app.search_query = SearchQuery::new("arrow head at end"); @@ -670,7 +541,7 @@ fn preset_slot_search_indexes_visible_slot_body_labels() { #[test] fn render_profile_mapping_match_preserves_mapping_identity() { - let (mut app, _task) = ConfiguratorApp::new_app(); + let (mut app, _effects) = ConfiguratorApp::new_app(); let mut profile = app.draft.render_profiles.new_profile(); profile.mappings[0].from = "#123456".to_string(); app.draft.render_profiles.profiles.push(profile); @@ -688,7 +559,7 @@ fn render_profile_mapping_match_preserves_mapping_identity() { #[test] fn exact_pdf_field_labels_match_pdf_section() { - let (mut app, _task) = ConfiguratorApp::new_app(); + let (mut app, _effects) = ConfiguratorApp::new_app(); app.search_query = SearchQuery::new("show pdf page labels"); let summary = app.search_summary(); @@ -699,7 +570,7 @@ fn exact_pdf_field_labels_match_pdf_section() { #[test] fn exact_pdf_background_field_label_matches_pdf_section() { - let (mut app, _task) = ConfiguratorApp::new_app(); + let (mut app, _effects) = ConfiguratorApp::new_app(); app.search_query = SearchQuery::new("transparent page background"); let summary = app.search_summary(); @@ -710,7 +581,7 @@ fn exact_pdf_background_field_label_matches_pdf_section() { #[test] fn exact_general_ui_field_labels_match_general_ui_section() { - let (mut app, _task) = ConfiguratorApp::new_app(); + let (mut app, _effects) = ConfiguratorApp::new_app(); app.search_query = SearchQuery::new("focus loss"); let summary = app.search_summary(); @@ -721,7 +592,7 @@ fn exact_general_ui_field_labels_match_general_ui_section() { #[test] fn exact_capture_file_labels_match_capture_file_section() { - let (mut app, _task) = ConfiguratorApp::new_app(); + let (mut app, _effects) = ConfiguratorApp::new_app(); app.search_query = SearchQuery::new("enable capture shortcuts"); let summary = app.search_summary(); @@ -756,7 +627,7 @@ fn exact_capture_filename_labels_match_capture_sections() { ]; for (query, expected_area, hidden_area) in cases { - let (mut app, _task) = ConfiguratorApp::new_app(); + let (mut app, _effects) = ConfiguratorApp::new_app(); app.search_query = SearchQuery::new(query); let summary = app.search_summary(); @@ -775,7 +646,7 @@ fn exact_capture_filename_labels_match_capture_sections() { #[test] fn disabled_preset_slots_do_not_match_hidden_body_controls() { - let (mut app, _task) = ConfiguratorApp::new_app(); + let (mut app, _effects) = ConfiguratorApp::new_app(); app.draft.presets.slot_mut(1).expect("slot 1").enabled = false; app.draft.presets.slot_mut(2).expect("slot 2").enabled = true; app.search_query = SearchQuery::new("arrow head at end"); @@ -789,7 +660,7 @@ fn disabled_preset_slots_do_not_match_hidden_body_controls() { #[test] fn daemon_area_terms_match_individual_sections() { - let (mut app, _task) = ConfiguratorApp::new_app(); + let (mut app, _effects) = ConfiguratorApp::new_app(); app.search_query = SearchQuery::new("light"); let summary = app.search_summary(); @@ -801,7 +672,7 @@ fn daemon_area_terms_match_individual_sections() { #[test] fn exact_keybinding_input_label_matches_keybinding_rows() { - let (mut app, _task) = ConfiguratorApp::new_app(); + let (mut app, _effects) = ConfiguratorApp::new_app(); app.search_query = SearchQuery::new("shortcut list"); let summary = app.search_summary(); @@ -813,7 +684,7 @@ fn exact_keybinding_input_label_matches_keybinding_rows() { #[test] fn eyedropper_search_exposes_screen_color_keybinding() { - let (mut app, _task) = ConfiguratorApp::new_app(); + let (mut app, _effects) = ConfiguratorApp::new_app(); app.search_query = SearchQuery::new("eyedropper"); let summary = app.search_summary(); diff --git a/configurator/src/app/search/types.rs b/configurator/src/app/search/types.rs index 89cdbeb8..4836626b 100644 --- a/configurator/src/app/search/types.rs +++ b/configurator/src/app/search/types.rs @@ -45,25 +45,6 @@ impl AppSearchSummary { self.query.is_active() } - pub(crate) fn raw_query(&self) -> &str { - self.query.raw() - } - - pub(crate) fn has_raw_input(&self) -> bool { - self.query.has_raw_input() - } - - pub(crate) fn total_matches(&self) -> usize { - if !self.is_active() { - return 0; - } - self.tabs.iter().map(TabSearchSummary::match_count).sum() - } - - pub(crate) fn tabs(&self) -> &[TabSearchSummary] { - &self.tabs - } - pub(crate) fn tab(&self, tab: TabId) -> Option<&TabSearchSummary> { self.tabs.iter().find(|summary| summary.tab == tab) } @@ -120,10 +101,6 @@ impl TabSearchSummary { } } - pub(crate) fn tab(&self) -> TabId { - self.tab - } - pub(crate) fn show_all(&self) -> bool { self.direct_title_match || self.alias_match } @@ -198,25 +175,6 @@ impl TabSearchSummary { || !self.keybinding_fields.is_empty() } - fn match_count(&self) -> usize { - let count = self.areas.len() - + self.ui_tabs.len() - + self.keybinding_tabs.len() - + self.direct_keybinding_tabs.len() - + self.board_indices.len() - + self.preset_slots.len() - + self.render_profile_indices.len() - + self.render_profile_mapping_indices.len() - + usize::from(self.session_catalog_all_items) - + self.session_item_ids.len() - + self.keybinding_fields.len(); - if self.show_all() && count == 0 { - 1 - } else { - count.max(1) - } - } - pub(super) fn add_area(&mut self, area: SearchArea) { push_unique(&mut self.areas, area); } diff --git a/configurator/src/app/session_catalog.rs b/configurator/src/app/session_catalog.rs index 61de0c16..6f10cc7b 100644 --- a/configurator/src/app/session_catalog.rs +++ b/configurator/src/app/session_catalog.rs @@ -5,7 +5,9 @@ use std::process::Command; use wayscriber::session::try_lock_exclusive; -use crate::models::{DaemonRuntimeStatus, SessionCatalogActionResult, SessionCatalogItem}; +use crate::models::{ + DaemonRuntimeStatus, SessionCatalogActionResult, SessionCatalogItem, SessionCatalogOperation, +}; use super::blocking_jobs::{BlockingJobKind, run_blocking}; use super::daemon_setup::load_daemon_runtime_status_sync; @@ -88,13 +90,14 @@ pub(super) async fn clear_session_catalog_entry( let item = find_session_catalog_item(&id)?; let _daemon_lock = acquire_runtime_lock_for_inactive_operation( RuntimeLockKind::Daemon, - CatalogOperation::Clear, + SessionCatalogOperation::Clear, )?; let _overlay_lock = acquire_runtime_lock_for_inactive_operation( RuntimeLockKind::Overlay, - CatalogOperation::Clear, + SessionCatalogOperation::Clear, )?; - if let Some(blocker) = service_status_blocker(Some(&status), CatalogOperation::Clear) { + if let Some(blocker) = service_status_blocker(Some(&status), SessionCatalogOperation::Clear) + { return Err(blocker); } @@ -121,14 +124,14 @@ pub(super) async fn clear_session_catalog_tool_state_entry( let item = find_session_catalog_item(&id)?; let _daemon_lock = acquire_runtime_lock_for_inactive_operation( RuntimeLockKind::Daemon, - CatalogOperation::ClearToolState, + SessionCatalogOperation::ClearToolState, )?; let _overlay_lock = acquire_runtime_lock_for_inactive_operation( RuntimeLockKind::Overlay, - CatalogOperation::ClearToolState, + SessionCatalogOperation::ClearToolState, )?; if let Some(blocker) = - service_status_blocker(Some(&status), CatalogOperation::ClearToolState) + service_status_blocker(Some(&status), SessionCatalogOperation::ClearToolState) { return Err(blocker); } @@ -145,43 +148,11 @@ pub(super) async fn clear_session_catalog_tool_state_entry( .await } -pub(super) fn session_clear_cached_status_blocker( - status: Option<&DaemonRuntimeStatus>, -) -> Option { - service_status_blocker(status, CatalogOperation::Clear) -} - -pub(super) fn session_clear_tool_state_cached_status_blocker( - status: Option<&DaemonRuntimeStatus>, -) -> Option { - service_status_blocker(status, CatalogOperation::ClearToolState) -} - -pub(super) fn session_duplicate_cached_status_blocker( - status: Option<&DaemonRuntimeStatus>, -) -> Option { - service_status_blocker(status, CatalogOperation::Duplicate) -} - -pub(super) fn session_move_cached_status_blocker( - status: Option<&DaemonRuntimeStatus>, -) -> Option { - service_status_blocker(status, CatalogOperation::Move) -} - fn service_status_blocker( status: Option<&DaemonRuntimeStatus>, - operation: CatalogOperation, + operation: SessionCatalogOperation, ) -> Option { - match status { - Some(status) if status.service_active => Some( - operation - .running_message(RuntimeLockKind::Daemon) - .to_string(), - ), - Some(_) => None, - None => Some(operation.waiting_for_status_message().to_string()), - } + operation.cached_status_blocker(status).map(str::to_string) } fn load_session_catalog_sync() -> Result, String> { @@ -272,14 +243,6 @@ enum RuntimeLockKind { Overlay, } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(super) enum CatalogOperation { - Clear, - ClearToolState, - Duplicate, - Move, -} - impl RuntimeLockKind { fn path(self) -> PathBuf { match self { @@ -296,56 +259,11 @@ impl RuntimeLockKind { } } -impl CatalogOperation { - fn running_message(self, kind: RuntimeLockKind) -> &'static str { - match (self, kind) { - (Self::Clear, RuntimeLockKind::Daemon) => { - "Clear saved data is disabled while the background service or a manually started daemon is running. Stop it first or clear from the overlay." - } - (Self::Clear, RuntimeLockKind::Overlay) => { - "Clear saved data is disabled while an overlay is running. Use the overlay Clear action for the active session." - } - (Self::ClearToolState, RuntimeLockKind::Daemon) => { - "Clear saved tool state is disabled while the background service or a manually started daemon is running. Use the overlay command palette for the active session or stop it first." - } - (Self::ClearToolState, RuntimeLockKind::Overlay) => { - "Clear saved tool state is disabled while an overlay is running. Use the command palette for the active session." - } - (Self::Duplicate, RuntimeLockKind::Daemon) => { - "Duplicate Session is disabled while the background service or a manually started daemon is running. Stop it first or duplicate from the overlay after opening the session." - } - (Self::Duplicate, RuntimeLockKind::Overlay) => { - "Duplicate Session is disabled while an overlay is running. Use Save As from the overlay for the active session." - } - (Self::Move, RuntimeLockKind::Daemon) => { - "Move Session is disabled while the background service or a manually started daemon is running. Stop it first or move from the overlay after opening the session." - } - (Self::Move, RuntimeLockKind::Overlay) => { - "Move Session is disabled while an overlay is running. Active session moves must use a runtime transaction." - } - } - } - - fn waiting_for_status_message(self) -> &'static str { - match self { - Self::Clear => { - "Clear saved data is disabled until background service status finishes loading." - } - Self::ClearToolState => { - "Clear saved tool state is disabled until background service status finishes loading." - } - Self::Duplicate => { - "Duplicate Session is disabled until background service status finishes loading." - } - Self::Move => { - "Move Session is disabled until background service status finishes loading." - } - } - } -} - #[cfg(test)] -fn runtime_lock_active(kind: RuntimeLockKind, operation: CatalogOperation) -> Result { +fn runtime_lock_active( + kind: RuntimeLockKind, + operation: SessionCatalogOperation, +) -> Result { let path = kind.path(); let file = match OpenOptions::new().read(true).write(true).open(&path) { Ok(file) => file, @@ -377,12 +295,12 @@ fn runtime_lock_active(kind: RuntimeLockKind, operation: CatalogOperation) -> Re #[cfg(test)] fn acquire_runtime_lock_for_clear(kind: RuntimeLockKind) -> Result { - acquire_runtime_lock_for_inactive_operation(kind, CatalogOperation::Clear) + acquire_runtime_lock_for_inactive_operation(kind, SessionCatalogOperation::Clear) } fn acquire_runtime_lock_for_inactive_operation( kind: RuntimeLockKind, - operation: CatalogOperation, + operation: SessionCatalogOperation, ) -> Result { let path = kind.path(); if let Some(parent) = path.parent() { @@ -414,7 +332,11 @@ fn acquire_runtime_lock_for_inactive_operation( match try_lock_exclusive(&file) { Ok(()) => Ok(file), Err(err) if err.kind() == ErrorKind::WouldBlock => { - Err(operation.running_message(kind).to_string()) + let message = match kind { + RuntimeLockKind::Daemon => operation.running_daemon_message(), + RuntimeLockKind::Overlay => operation.running_overlay_message(), + }; + Err(message.to_string()) } Err(err) => Err(format!( "{} is disabled because the {} lock could not be reserved: {} ({err})", @@ -425,25 +347,10 @@ fn acquire_runtime_lock_for_inactive_operation( } } -impl CatalogOperation { - fn label(self) -> &'static str { - match self { - Self::Clear => "Clear saved data", - Self::ClearToolState => "Clear saved tool state", - Self::Duplicate => "Duplicate Session", - Self::Move => "Move Session", - } - } -} - #[cfg(test)] fn drop_lock(file: File) { drop(file); } -pub(super) fn session_artifact_status_label(item: &SessionCatalogItem) -> String { - item.artifacts.status_label() -} - #[cfg(test)] mod tests; diff --git a/configurator/src/app/session_catalog/duplicate.rs b/configurator/src/app/session_catalog/duplicate.rs index fc0dc161..b1610a3c 100644 --- a/configurator/src/app/session_catalog/duplicate.rs +++ b/configurator/src/app/session_catalog/duplicate.rs @@ -1,10 +1,10 @@ use std::path::{Path, PathBuf}; -use crate::models::{DaemonRuntimeStatus, SessionCatalogActionResult}; +use crate::models::{DaemonRuntimeStatus, SessionCatalogActionResult, SessionCatalogOperation}; use super::{ - CatalogOperation, RuntimeLockKind, acquire_runtime_lock_for_inactive_operation, - load_session_catalog_sync, service_status_blocker, + RuntimeLockKind, acquire_runtime_lock_for_inactive_operation, load_session_catalog_sync, + service_status_blocker, }; use super::super::blocking_jobs::{BlockingJobKind, run_blocking}; @@ -34,13 +34,14 @@ fn duplicate_session_catalog_entry_sync( .ok_or_else(|| "Session is no longer in the catalog.".to_string())?; let _daemon_lock = acquire_runtime_lock_for_inactive_operation( RuntimeLockKind::Daemon, - CatalogOperation::Duplicate, + SessionCatalogOperation::Duplicate, )?; let _overlay_lock = acquire_runtime_lock_for_inactive_operation( RuntimeLockKind::Overlay, - CatalogOperation::Duplicate, + SessionCatalogOperation::Duplicate, )?; - if let Some(blocker) = service_status_blocker(Some(status), CatalogOperation::Duplicate) { + if let Some(blocker) = service_status_blocker(Some(status), SessionCatalogOperation::Duplicate) + { return Err(blocker); } diff --git a/configurator/src/app/session_catalog/move_file.rs b/configurator/src/app/session_catalog/move_file.rs index 3b31f17d..1667b3bd 100644 --- a/configurator/src/app/session_catalog/move_file.rs +++ b/configurator/src/app/session_catalog/move_file.rs @@ -1,12 +1,14 @@ use std::path::{Path, PathBuf}; -use crate::models::{DaemonRuntimeStatus, SessionCatalogActionResult, SessionCatalogItem}; +use crate::models::{ + DaemonRuntimeStatus, SessionCatalogActionResult, SessionCatalogItem, SessionCatalogOperation, +}; use super::super::blocking_jobs::{BlockingJobKind, run_blocking}; use super::super::daemon_setup::load_daemon_runtime_status_sync; use super::{ - CatalogOperation, RuntimeLockKind, acquire_runtime_lock_for_inactive_operation, - load_session_catalog_sync, service_status_blocker, + RuntimeLockKind, acquire_runtime_lock_for_inactive_operation, load_session_catalog_sync, + service_status_blocker, }; pub(crate) async fn move_session_catalog_entry( @@ -35,13 +37,13 @@ fn move_session_catalog_entry_sync( let _daemon_lock = acquire_runtime_lock_for_inactive_operation( RuntimeLockKind::Daemon, - CatalogOperation::Move, + SessionCatalogOperation::Move, )?; let _overlay_lock = acquire_runtime_lock_for_inactive_operation( RuntimeLockKind::Overlay, - CatalogOperation::Move, + SessionCatalogOperation::Move, )?; - if let Some(blocker) = service_status_blocker(Some(status), CatalogOperation::Move) { + if let Some(blocker) = service_status_blocker(Some(status), SessionCatalogOperation::Move) { return Err(blocker); } diff --git a/configurator/src/app/session_catalog/tests.rs b/configurator/src/app/session_catalog/tests.rs index 55a70c5d..ac522a67 100644 --- a/configurator/src/app/session_catalog/tests.rs +++ b/configurator/src/app/session_catalog/tests.rs @@ -55,63 +55,68 @@ fn daemon_status(active: bool) -> DaemonRuntimeStatus { } #[test] -fn session_clear_cached_status_blocker_blocks_running_daemon() { +fn clear_policy_blocks_running_daemon() { let temp = crate::test_temp::tempdir().unwrap(); let _env = RuntimeEnvGuard::set_xdg_runtime_dir(temp.path()); let status = daemon_status(true); - let blocker = - session_clear_cached_status_blocker(Some(&status)).expect("daemon should block clear"); + let blocker = SessionCatalogOperation::Clear + .cached_status_blocker(Some(&status)) + .expect("daemon should block clear"); assert!(blocker.contains("background service")); } #[test] -fn session_clear_cached_status_blocker_allows_inactive_daemon() { +fn clear_policy_allows_inactive_daemon() { let temp = crate::test_temp::tempdir().unwrap(); let _env = RuntimeEnvGuard::set_xdg_runtime_dir(temp.path()); let status = daemon_status(false); - let blocker = session_clear_cached_status_blocker(Some(&status)); + let blocker = SessionCatalogOperation::Clear.cached_status_blocker(Some(&status)); assert!(blocker.is_none(), "{blocker:?}"); } #[test] -fn session_duplicate_cached_status_blocker_uses_duplicate_status_message() { +fn duplicate_policy_uses_duplicate_status_message() { let temp = crate::test_temp::tempdir().unwrap(); let _env = RuntimeEnvGuard::set_xdg_runtime_dir(temp.path()); - let blocker = session_duplicate_cached_status_blocker(None) + let blocker = SessionCatalogOperation::Duplicate + .cached_status_blocker(None) .expect("unknown status should block duplicate"); assert!(blocker.contains("Duplicate Session")); } #[test] -fn session_move_cached_status_blocker_uses_move_status_message() { +fn move_policy_uses_move_status_message() { let temp = crate::test_temp::tempdir().unwrap(); let _env = RuntimeEnvGuard::set_xdg_runtime_dir(temp.path()); - let blocker = - session_move_cached_status_blocker(None).expect("unknown status should block move"); + let blocker = SessionCatalogOperation::Move + .cached_status_blocker(None) + .expect("unknown status should block move"); assert!(blocker.contains("Move Session")); } #[test] -fn session_clear_cached_status_blocker_blocks_unknown_daemon_status() { +fn clear_policy_blocks_unknown_daemon_status() { let temp = crate::test_temp::tempdir().unwrap(); let _env = RuntimeEnvGuard::set_xdg_runtime_dir(temp.path()); - let blocker = - session_clear_cached_status_blocker(None).expect("unknown status should block clear"); + let blocker = SessionCatalogOperation::Clear + .cached_status_blocker(None) + .expect("unknown status should block clear"); assert!(blocker.contains("status finishes loading")); } #[test] -fn session_clear_tool_state_cached_status_blocker_uses_tool_state_status_message() { +fn clear_tool_state_policy_uses_tool_state_status_message() { let temp = crate::test_temp::tempdir().unwrap(); let _env = RuntimeEnvGuard::set_xdg_runtime_dir(temp.path()); - let blocker = session_clear_tool_state_cached_status_blocker(None) + let blocker = SessionCatalogOperation::ClearToolState + .cached_status_blocker(None) .expect("unknown status should block tool reset"); assert!(blocker.contains("Clear saved tool state")); @@ -125,7 +130,7 @@ fn session_clear_transaction_guard_blocks_manual_daemon_lock() { let _daemon_lock = acquire_runtime_lock_for_clear(RuntimeLockKind::Daemon).unwrap(); let error = acquire_runtime_lock_for_inactive_operation( RuntimeLockKind::Daemon, - CatalogOperation::Clear, + SessionCatalogOperation::Clear, ) .expect_err("held daemon lock should block the transaction"); @@ -140,10 +145,10 @@ fn cached_status_blocker_does_not_probe_runtime_locks() { let _overlay_lock = acquire_runtime_lock_for_clear(RuntimeLockKind::Overlay).unwrap(); let status = daemon_status(false); - let blocker = session_clear_cached_status_blocker(Some(&status)); + let blocker = SessionCatalogOperation::Clear.cached_status_blocker(Some(&status)); assert!(blocker.is_none(), "{blocker:?}"); - let blocker = session_move_cached_status_blocker(Some(&status)); + let blocker = SessionCatalogOperation::Move.cached_status_blocker(Some(&status)); assert!(blocker.is_none(), "{blocker:?}"); } @@ -156,17 +161,17 @@ fn clear_runtime_guards_hold_daemon_and_overlay_locks() { let _overlay_lock = acquire_runtime_lock_for_clear(RuntimeLockKind::Overlay).unwrap(); assert!(matches!( - runtime_lock_active(RuntimeLockKind::Daemon, CatalogOperation::Clear), + runtime_lock_active(RuntimeLockKind::Daemon, SessionCatalogOperation::Clear), Ok(true) )); assert!(matches!( - runtime_lock_active(RuntimeLockKind::Overlay, CatalogOperation::Clear), + runtime_lock_active(RuntimeLockKind::Overlay, SessionCatalogOperation::Clear), Ok(true) )); } #[test] -fn session_artifact_status_label_reports_size_when_present() { +fn session_artifact_status_reports_size_when_present() { let item = SessionCatalogItem { id: "s-1".to_string(), display_name: "Lecture".to_string(), @@ -186,7 +191,7 @@ fn session_artifact_status_label_reports_size_when_present() { }, }; - assert_eq!(session_artifact_status_label(&item), "primary · 4.0 KiB"); + assert_eq!(item.artifacts.status_label(), "primary · 4.0 KiB"); } #[test] diff --git a/configurator/src/app/startup.rs b/configurator/src/app/startup.rs index 7a1d574f..1087254a 100644 --- a/configurator/src/app/startup.rs +++ b/configurator/src/app/startup.rs @@ -1,9 +1,8 @@ -use iced::Task; use wayscriber::configurator_destination::{ConfiguratorScreen, KeybindingsSection}; -use crate::messages::Message; use crate::models::{KeybindingsTabId, SearchQuery, TabId, UiTabId}; +use super::effects::Effect; use super::state::ConfiguratorApp; impl ConfiguratorApp { @@ -12,7 +11,7 @@ impl ConfiguratorApp { /// Consumed by the first config load and empty from then on: a later /// Reload is the user asking for the file again, not a fresh launch, and /// snapping the tabs back would undo wherever they navigated in between. - pub(crate) fn apply_startup_request(&mut self) -> Task { + pub(crate) fn apply_startup_request(&mut self) -> Vec { let request = std::mem::take(&mut self.startup_request); if let Some(problem) = request.problem() { @@ -47,8 +46,7 @@ impl ConfiguratorApp { // A plain screen request is not a search: focusing the search // box would send the first keystroke to the filter instead of // to the screen the user asked for. - self.search_input_focus_hint = false; - Task::none() + Vec::new() } } } @@ -101,13 +99,13 @@ fn keybindings_tab(section: KeybindingsSection) -> KeybindingsTabId { #[cfg(test)] mod tests { use std::path::Path; - use std::sync::Arc; use wayscriber::config::ConfigDocument; use wayscriber::configurator_destination::ConfiguratorDestination; use super::*; use crate::app::state::StatusMessage; + use crate::messages::CommandMessage; use crate::models::StartupRequest; use crate::test_temp::{TempDir, tempdir}; @@ -140,13 +138,13 @@ mod tests { /// `load_config_from_disk` task delivers. fn load_config_file(app: &mut ConfiguratorApp, path: &Path) { let document = ConfigDocument::load_from_path(path).expect("load test config document"); - let _ = app.update_message(Message::ConfigLoaded(Ok((Arc::new(document), None)))); + let _ = app.update_command(CommandMessage::ConfigLoaded(Ok((Box::new(document), None)))); } /// The app as the launcher would start it, after its first config load. fn app_launched_with(values: &[&str]) -> (ConfiguratorApp, TempDir, std::path::PathBuf) { let (dir, path) = config_file(); - let (mut app, _cmd) = + let (mut app, _effects) = ConfiguratorApp::new_app_with_startup(StartupRequest::from_args(args(values))); load_config_file(&mut app, &path); (app, dir, path) @@ -158,9 +156,9 @@ mod tests { assert_eq!(app.active_tab, TabId::Daemon); assert_eq!(app.active_ui_tab, UiTabId::Toolbar); - assert!(app.search_input_focus_hint); + assert_eq!(app.search_focus_serial, 1); assert!(!app.startup_search_focus_pending); - assert!(!app.search_query.has_raw_input()); + assert!(app.search_query.raw().is_empty()); } #[test] @@ -223,13 +221,9 @@ mod tests { assert_eq!(app.active_tab, TabId::Drawing); assert_eq!(app.search_query.raw(), "Quick Colors"); - assert!(app.search_summary().total_matches() > 0); - assert!( - app.search_summary().tabs().len() > 1, - "the term is expected to match more than the destination tab" - ); + assert!(app.search_summary().tab(TabId::Drawing).is_some()); // A term means the search box has content to edit or clear. - assert!(app.search_input_focus_hint); + assert_eq!(app.search_focus_serial, 1); assert!(!app.startup_search_focus_pending); } @@ -242,7 +236,7 @@ mod tests { assert_eq!(app.active_tab, TabId::Keybindings); assert_eq!(app.active_keybindings_tab, KeybindingsTabId::Drawing); assert_eq!(app.search_query.raw(), "Clear Canvas"); - assert!(app.search_summary().total_matches() > 0); + assert!(app.search_summary().tab(TabId::Keybindings).is_some()); } /// The same correction as the tab-level one, a level down: a subtab that @@ -263,16 +257,16 @@ mod tests { let (app, _dir, _path) = app_launched_with(&["--open=boards?search=input hud"]); assert_ne!(app.active_tab, TabId::Boards); - assert!(app.search_summary().total_matches() > 0); + assert!(app.search_summary().tab(app.active_tab).is_some()); } #[test] fn a_plain_screen_request_does_not_take_the_search_focus() { let (app, _dir, _path) = app_launched_with(&["--open", "ui/status-bar"]); - assert!(!app.search_input_focus_hint); + assert_eq!(app.search_focus_serial, 0); assert!(!app.startup_search_focus_pending); - assert!(!app.search_query.has_raw_input()); + assert!(app.search_query.raw().is_empty()); } #[test] @@ -286,7 +280,7 @@ mod tests { // The load result is still reported alongside the note. assert!(text.contains("Configuration loaded from disk."), "{text}"); // Falling back means the ordinary first screen, focus included. - assert!(app.search_input_focus_hint); + assert_eq!(app.search_focus_serial, 1); assert!(!app.startup_search_focus_pending); } @@ -316,12 +310,12 @@ mod tests { #[test] fn a_failed_first_load_still_honors_the_destination() { - let (mut app, _cmd) = + let (mut app, _effects) = ConfiguratorApp::new_app_with_startup(StartupRequest::from_args(args(&[ "--open", "boards", ]))); - let _ = app.update_message(Message::ConfigLoaded(Err("broken".to_string()))); + let _ = app.update_command(CommandMessage::ConfigLoaded(Err("broken".to_string()))); assert_eq!(app.active_tab, TabId::Boards); } diff --git a/configurator/src/app/state.rs b/configurator/src/app/state.rs index 36ab9bcf..f2834598 100644 --- a/configurator/src/app/state.rs +++ b/configurator/src/app/state.rs @@ -1,29 +1,28 @@ -use std::collections::{HashMap, HashSet}; -use std::{path::PathBuf, sync::Arc}; +use std::collections::HashMap; +use std::path::PathBuf; -use iced::Task; use wayscriber::config::{ Config, ConfigDocument, ConfigValidationReport, MigrationPreview, PRESET_SLOTS_MAX, }; -use crate::messages::Message; use crate::models::{ ColorPickerId, ConfigDraft, DaemonRuntimeStatus, DesktopEnvironment, DragMouseButton, KeybindingsTabId, SearchQuery, SessionCatalogState, StartupRequest, TabId, ToolbarLayoutModeOption, UiTabId, }; -use super::daemon_setup::load_daemon_runtime_status; -use super::io::load_config_from_disk; -use super::session_catalog::load_session_catalog; +use super::effects::Effect; #[derive(Debug)] pub(crate) struct ConfiguratorApp { pub(crate) draft: ConfigDraft, pub(crate) baseline: ConfigDraft, pub(crate) defaults: ConfigDraft, - // The source document owns typed config, lossless TOML, and the guarded save revision. - pub(crate) base_document: Option>, + // The source document owns typed config, lossless TOML, and the guarded + // save revision. Owned outright and moved into a running save, so it is + // `None` exactly while a write holds it and for as long as no load has + // produced one. + pub(crate) base_document: Option, pub(crate) status: StatusMessage, pub(crate) active_tab: TabId, pub(crate) active_ui_tab: UiTabId, @@ -31,8 +30,6 @@ pub(crate) struct ConfiguratorApp { pub(crate) active_drawing_drag_button: Option, pub(crate) preset_collapsed: Vec, pub(crate) boards_collapsed: Vec, - pub(crate) color_picker_open: Option, - pub(crate) color_picker_advanced: HashSet, pub(crate) color_picker_hex: HashMap, pub(crate) override_mode: ToolbarLayoutModeOption, pub(crate) is_loading: bool, @@ -64,7 +61,10 @@ pub(crate) struct ConfiguratorApp { pub(crate) daemon_preserve_feedback_status_request_id: Option, pub(crate) session_catalog: SessionCatalogState, pub(crate) search_query: SearchQuery, - pub(crate) search_input_focus_hint: bool, + /// Bumped once per request to put the caret in the search box. The shell + /// grabs focus when the serial it last honored falls behind, so a request + /// focuses once instead of on every view refresh. + pub(crate) search_focus_serial: u64, pub(crate) startup_search_focus_pending: bool, /// What the launching process asked to open, taken by the first config /// load and empty from then on. @@ -123,11 +123,11 @@ impl ConfiguratorApp { /// Test-only: the binary always has a parsed launch request to pass, even /// when it is the empty one. #[cfg(test)] - pub(crate) fn new_app() -> (Self, Task) { + pub(crate) fn new_app() -> (Self, Vec) { Self::new_app_with_startup(StartupRequest::default()) } - pub(crate) fn new_app_with_startup(startup: StartupRequest) -> (Self, Task) { + pub(crate) fn new_app_with_startup(startup: StartupRequest) -> (Self, Vec) { let default_config = Config::default(); let defaults = ConfigDraft::from_config(&default_config); let baseline = defaults.clone(); @@ -147,8 +147,6 @@ impl ConfiguratorApp { active_drawing_drag_button: None, preset_collapsed: vec![false; PRESET_SLOTS_MAX], boards_collapsed: vec![false; boards_len], - color_picker_open: None, - color_picker_advanced: HashSet::new(), color_picker_hex: HashMap::new(), override_mode, is_loading: true, @@ -168,22 +166,22 @@ impl ConfiguratorApp { daemon_preserve_feedback_status_request_id: None, session_catalog: SessionCatalogState::loading(), search_query: SearchQuery::default(), - search_input_focus_hint: true, + search_focus_serial: 0, startup_search_focus_pending: true, startup_request: startup, }; app.sync_all_color_picker_hex(); let initial_status_request_id = app.daemon_latest_status_request_id; - let command = Task::batch(vec![ - Task::perform(load_config_from_disk(), Message::ConfigLoaded), - Task::perform(load_daemon_runtime_status(), move |result| { - Message::DaemonStatusLoaded(initial_status_request_id, result) - }), - Task::perform(load_session_catalog(), Message::SessionCatalogLoaded), - ]); - - (app, command) + let effects = vec![ + Effect::LoadConfig, + Effect::LoadDaemonStatus { + request_id: initial_status_request_id, + }, + Effect::LoadSessionCatalog, + ]; + + (app, effects) } pub(super) fn refresh_dirty_flag(&mut self) { @@ -218,7 +216,7 @@ mod tests { #[test] fn refresh_dirty_flag_tracks_draft_vs_baseline() { - let (mut app, _cmd) = ConfiguratorApp::new_app(); + let (mut app, _effects) = ConfiguratorApp::new_app(); app.refresh_dirty_flag(); assert!(!app.is_dirty); @@ -228,10 +226,18 @@ mod tests { } #[test] - fn new_app_starts_with_search_focus_hint() { - let (app, _cmd) = ConfiguratorApp::new_app(); + fn new_app_starts_with_the_startup_focus_offer_open() { + let (app, effects) = ConfiguratorApp::new_app(); - assert!(app.search_input_focus_hint); + assert_eq!(app.search_focus_serial, 0); assert!(app.startup_search_focus_pending); + assert!(matches!( + effects.as_slice(), + [ + Effect::LoadConfig, + Effect::LoadDaemonStatus { .. }, + Effect::LoadSessionCatalog + ] + )); } } diff --git a/configurator/src/app/subscription.rs b/configurator/src/app/subscription.rs deleted file mode 100644 index 270f8539..00000000 --- a/configurator/src/app/subscription.rs +++ /dev/null @@ -1,62 +0,0 @@ -use iced::{Event, Subscription, event, mouse, touch}; - -use crate::messages::Message; - -use super::state::ConfiguratorApp; - -impl ConfiguratorApp { - pub(crate) fn subscription(&self) -> Subscription { - event::listen_with(|event, status, _window| message_for_runtime_event(event, status)) - } -} - -fn message_for_runtime_event(event: Event, status: event::Status) -> Option { - match event { - Event::Keyboard(keyboard_event) => Some(Message::KeyboardEvent(keyboard_event, status)), - Event::Mouse(mouse::Event::ButtonPressed(_)) - | Event::Touch(touch::Event::FingerPressed { .. }) => Some(Message::PointerPressed), - _ => None, - } -} - -#[cfg(test)] -mod tests { - use super::*; - use iced::{Point, touch, window}; - - #[test] - fn window_focus_does_not_request_startup_search_focus() { - let message = message_for_runtime_event( - Event::Window(window::Event::Focused), - event::Status::Ignored, - ); - - assert!(message.is_none()); - } - - #[test] - fn touch_press_clears_search_focus_hint_like_mouse_press() { - let message = message_for_runtime_event( - Event::Touch(touch::Event::FingerPressed { - id: touch::Finger(1), - position: Point::ORIGIN, - }), - event::Status::Ignored, - ); - - assert!(matches!(message, Some(Message::PointerPressed))); - } - - #[test] - fn touch_move_does_not_clear_search_focus_hint() { - let message = message_for_runtime_event( - Event::Touch(touch::Event::FingerMoved { - id: touch::Finger(1), - position: Point::ORIGIN, - }), - event::Status::Ignored, - ); - - assert!(message.is_none()); - } -} diff --git a/configurator/src/app/update/boards.rs b/configurator/src/app/update/boards.rs index 0f038219..ef3f4dab 100644 --- a/configurator/src/app/update/boards.rs +++ b/configurator/src/app/update/boards.rs @@ -1,14 +1,12 @@ -use iced::Task; - -use crate::messages::Message; use crate::models::{ BoardBackgroundOption, BoardItemTextField, BoardItemToggleField, ColorPickerId, }; +use super::super::effects::Effect; use super::super::state::{ConfiguratorApp, StatusMessage}; impl ConfiguratorApp { - pub(super) fn handle_boards_add_item(&mut self) -> Task { + pub(super) fn handle_boards_add_item(&mut self) -> Vec { self.status = StatusMessage::idle(); let new_item = self.draft.boards.new_item(); self.draft.boards.items.push(new_item); @@ -16,10 +14,10 @@ impl ConfiguratorApp { self.clear_board_color_pickers(); self.draft.boards.ensure_default_exists(); self.refresh_dirty_flag(); - Task::none() + Vec::new() } - pub(super) fn handle_boards_remove_item(&mut self, index: usize) -> Task { + pub(super) fn handle_boards_remove_item(&mut self, index: usize) -> Vec { self.status = StatusMessage::idle(); if index < self.draft.boards.items.len() { self.draft.boards.items.remove(index); @@ -30,23 +28,23 @@ impl ConfiguratorApp { self.draft.boards.ensure_default_exists(); self.refresh_dirty_flag(); } - Task::none() + Vec::new() } - pub(super) fn handle_boards_move_item(&mut self, index: usize, up: bool) -> Task { + pub(super) fn handle_boards_move_item(&mut self, index: usize, up: bool) -> Vec { self.status = StatusMessage::idle(); let len = self.draft.boards.items.len(); if len <= 1 { - return Task::none(); + return Vec::new(); } let target = if up { if index == 0 { - return Task::none(); + return Vec::new(); } index - 1 } else { if index + 1 >= len { - return Task::none(); + return Vec::new(); } index + 1 }; @@ -56,10 +54,10 @@ impl ConfiguratorApp { } self.clear_board_color_pickers(); self.refresh_dirty_flag(); - Task::none() + Vec::new() } - pub(super) fn handle_boards_duplicate_item(&mut self, index: usize) -> Task { + pub(super) fn handle_boards_duplicate_item(&mut self, index: usize) -> Vec { self.status = StatusMessage::idle(); if let Some(item) = self.draft.boards.items.get(index).cloned() { let mut duplicate = item; @@ -74,21 +72,21 @@ impl ConfiguratorApp { self.draft.boards.ensure_default_exists(); self.refresh_dirty_flag(); } - Task::none() + Vec::new() } - pub(super) fn handle_boards_collapse_toggled(&mut self, index: usize) -> Task { + pub(super) fn handle_boards_collapse_toggled(&mut self, index: usize) -> Vec { if let Some(value) = self.boards_collapsed.get_mut(index) { *value = !*value; } - Task::none() + Vec::new() } - pub(super) fn handle_boards_default_changed(&mut self, value: String) -> Task { + pub(super) fn handle_boards_default_changed(&mut self, value: String) -> Vec { self.status = StatusMessage::idle(); self.draft.boards.default_board = value; self.refresh_dirty_flag(); - Task::none() + Vec::new() } pub(super) fn handle_boards_item_text_changed( @@ -96,7 +94,7 @@ impl ConfiguratorApp { index: usize, field: BoardItemTextField, value: String, - ) -> Task { + ) -> Vec { self.status = StatusMessage::idle(); let old_effective_id = self.draft.boards.effective_id_for_index(index); if let Some(item) = self.draft.boards.items.get_mut(index) { @@ -122,20 +120,28 @@ impl ConfiguratorApp { } self.draft.boards.ensure_default_exists(); self.refresh_dirty_flag(); - Task::none() + Vec::new() } pub(super) fn handle_boards_background_kind_changed( &mut self, index: usize, value: BoardBackgroundOption, - ) -> Task { + ) -> Vec { self.status = StatusMessage::idle(); - if let Some(item) = self.draft.boards.items.get_mut(index) { + let changed = if let Some(item) = self.draft.boards.items.get_mut(index) { item.background_kind = value; + true + } else { + false + }; + if changed && value != BoardBackgroundOption::Color { + // The picker is no longer reachable. Drop any half-typed buffer + // and restore its canonical draft value so it cannot block Save. + self.sync_color_picker_hex_for_id(ColorPickerId::BoardBackground(index)); } self.refresh_dirty_flag(); - Task::none() + Vec::new() } pub(super) fn handle_boards_background_color_changed( @@ -143,27 +149,35 @@ impl ConfiguratorApp { index: usize, component: usize, value: String, - ) -> Task { + ) -> Vec { self.status = StatusMessage::idle(); if let Some(item) = self.draft.boards.items.get_mut(index) { item.background_color.set_component(component, value); } self.sync_color_picker_hex_for_id(ColorPickerId::BoardBackground(index)); self.refresh_dirty_flag(); - Task::none() + Vec::new() } pub(super) fn handle_boards_default_pen_enabled_changed( &mut self, index: usize, value: bool, - ) -> Task { + ) -> Vec { self.status = StatusMessage::idle(); - if let Some(item) = self.draft.boards.items.get_mut(index) { + let changed = if let Some(item) = self.draft.boards.items.get_mut(index) { item.default_pen_color.enabled = value; + true + } else { + false + }; + if changed && !value { + // Disabling the override hides this required-looking editor, so + // abandon any incomplete text with the control that held it. + self.sync_color_picker_hex_for_id(ColorPickerId::BoardPen(index)); } self.refresh_dirty_flag(); - Task::none() + Vec::new() } pub(super) fn handle_boards_default_pen_color_changed( @@ -171,14 +185,14 @@ impl ConfiguratorApp { index: usize, component: usize, value: String, - ) -> Task { + ) -> Vec { self.status = StatusMessage::idle(); if let Some(item) = self.draft.boards.items.get_mut(index) { item.default_pen_color.color.set_component(component, value); } self.sync_color_picker_hex_for_id(ColorPickerId::BoardPen(index)); self.refresh_dirty_flag(); - Task::none() + Vec::new() } pub(super) fn handle_boards_item_toggle_changed( @@ -186,7 +200,7 @@ impl ConfiguratorApp { index: usize, field: BoardItemToggleField, value: bool, - ) -> Task { + ) -> Vec { self.status = StatusMessage::idle(); if let Some(item) = self.draft.boards.items.get_mut(index) { match field { @@ -196,28 +210,16 @@ impl ConfiguratorApp { } } self.refresh_dirty_flag(); - Task::none() + Vec::new() } fn clear_board_color_pickers(&mut self) { - if matches!( - self.color_picker_open, - Some(ColorPickerId::BoardBackground(_) | ColorPickerId::BoardPen(_)) - ) { - self.color_picker_open = None; - } self.color_picker_hex.retain(|id, _| { !matches!( id, ColorPickerId::BoardBackground(_) | ColorPickerId::BoardPen(_) ) }); - self.color_picker_advanced.retain(|id| { - !matches!( - id, - ColorPickerId::BoardBackground(_) | ColorPickerId::BoardPen(_) - ) - }); self.sync_board_color_picker_hex(); } } @@ -225,11 +227,10 @@ impl ConfiguratorApp { #[cfg(test)] mod tests { use super::*; - use crate::models::ColorPickerId; #[test] fn add_item_updates_boards_and_collapsed_state() { - let (mut app, _cmd) = ConfiguratorApp::new_app(); + let (mut app, _effects) = ConfiguratorApp::new_app(); let before = app.draft.boards.items.len(); let _ = app.handle_boards_add_item(); @@ -241,7 +242,7 @@ mod tests { #[test] fn duplicate_item_inserts_copy_with_new_id() { - let (mut app, _cmd) = ConfiguratorApp::new_app(); + let (mut app, _effects) = ConfiguratorApp::new_app(); let before = app.draft.boards.items.len(); let original_id = app .draft @@ -259,14 +260,40 @@ mod tests { } #[test] - fn remove_item_clears_board_color_picker_when_targeting_board_entry() { - let (mut app, _cmd) = ConfiguratorApp::new_app(); + fn remove_item_keeps_collapsed_state_in_step_with_the_board_list() { + let (mut app, _effects) = ConfiguratorApp::new_app(); let _ = app.handle_boards_add_item(); - app.color_picker_open = Some(ColorPickerId::BoardBackground(0)); let _ = app.handle_boards_remove_item(0); - assert!(app.color_picker_open.is_none()); assert_eq!(app.boards_collapsed.len(), app.draft.boards.items.len()); } + + #[test] + fn hiding_a_board_background_color_releases_its_invalid_hex() { + let (mut app, _effects) = ConfiguratorApp::new_app(); + let _ = app.handle_boards_background_kind_changed(0, BoardBackgroundOption::Color); + let _ = app.handle_color_picker_hex_changed( + ColorPickerId::BoardBackground(0), + "#12zz".to_string(), + ); + assert_eq!(app.invalid_color_hex_count(), 1); + + let _ = app.handle_boards_background_kind_changed(0, BoardBackgroundOption::Transparent); + + assert_eq!(app.invalid_color_hex_count(), 0); + } + + #[test] + fn disabling_a_board_pen_override_releases_its_invalid_hex() { + let (mut app, _effects) = ConfiguratorApp::new_app(); + let _ = app.handle_boards_default_pen_enabled_changed(0, true); + let _ = + app.handle_color_picker_hex_changed(ColorPickerId::BoardPen(0), "#12zz".to_string()); + assert_eq!(app.invalid_color_hex_count(), 1); + + let _ = app.handle_boards_default_pen_enabled_changed(0, false); + + assert_eq!(app.invalid_color_hex_count(), 0); + } } diff --git a/configurator/src/app/update/color_picker.rs b/configurator/src/app/update/color_picker.rs index 25e386ee..85f76fd4 100644 --- a/configurator/src/app/update/color_picker.rs +++ b/configurator/src/app/update/color_picker.rs @@ -1,49 +1,24 @@ -use iced::Task; - -use crate::messages::Message; use crate::models::color::{ - hex_from_rgb, hex_from_rgba, parse_hex, parse_quad_values, parse_triplet_values, + hex_field_error, hex_from_rgb, hex_from_rgba, parse_hex, parse_quad_values, + parse_triplet_values, }; use crate::models::util::format_float; -use crate::models::{ColorPickerId, ColorPickerValue, QuadField}; +use crate::models::{ColorPickerId, QuadField}; +use super::super::effects::Effect; use super::super::state::{ConfiguratorApp, StatusMessage}; impl ConfiguratorApp { - pub(super) fn handle_color_picker_toggled(&mut self, id: ColorPickerId) -> Task { - if self.color_picker_open == Some(id) { - self.color_picker_open = None; - return Task::none(); - } - - self.color_picker_open = Some(id); - self.sync_color_picker_hex_for_id(id); - Task::none() - } - - pub(super) fn handle_color_picker_advanced_toggled( - &mut self, - id: ColorPickerId, - value: bool, - ) -> Task { - if value { - self.color_picker_advanced.insert(id); - } else { - self.color_picker_advanced.remove(&id); - } - Task::none() - } - pub(super) fn handle_color_picker_hex_changed( &mut self, id: ColorPickerId, value: String, - ) -> Task { + ) -> Vec { self.status = StatusMessage::idle(); self.color_picker_hex.insert(id, value.clone()); if let Some((rgb, alpha)) = parse_hex(&value) { - let alpha = if self.color_picker_uses_alpha(id) { + let alpha = if id.uses_alpha() { alpha.or_else(|| self.current_alpha_for_id(id)) } else { None @@ -52,23 +27,34 @@ impl ConfiguratorApp { } self.refresh_dirty_flag(); - Task::none() + Vec::new() } - pub(super) fn handle_color_picker_changed( - &mut self, - id: ColorPickerId, - value: ColorPickerValue, - ) -> Task { - self.status = StatusMessage::idle(); - self.apply_color_picker_value(id, value.rgb, value.alpha); - self.refresh_dirty_flag(); - Task::none() + /// How many color fields hold text that is not a color. + /// + /// [`Self::handle_color_picker_hex_changed`] keeps every keystroke as text + /// and only writes a value the parser accepted into the draft, so text it + /// rejects is an edit that has not landed anywhere. Saving over it would + /// write whatever last parsed and then let the reload's + /// [`Self::sync_all_color_picker_hex`] replace the text with that value — + /// the user's half-typed color gone without a word. Counting them is what + /// lets the Save path refuse instead. + /// + /// An emptied field counts too: every picker here edits a required color, + /// so clearing one is the same unwritable edit as mistyping one, and the + /// same silent overwrite would follow. [`hex_field_error`] is the single + /// predicate this and the rows' error styling share, so the fields the + /// user sees marked are exactly the fields counted here. + pub(crate) fn invalid_color_hex_count(&self) -> usize { + self.color_picker_hex + .values() + .filter(|text| hex_field_error(text).is_some()) + .count() } pub(super) fn sync_color_picker_hex_for_id(&mut self, id: ColorPickerId) { if let Some((rgb, alpha)) = self.current_color_for_id(id) { - let hex = if self.color_picker_uses_alpha(id) { + let hex = if id.uses_alpha() { let alpha = alpha.unwrap_or(1.0); hex_from_rgba([rgb[0], rgb[1], rgb[2], alpha]) } else { @@ -304,21 +290,6 @@ impl ConfiguratorApp { fn current_alpha_for_id(&self, id: ColorPickerId) -> Option { self.current_color_for_id(id).and_then(|(_, alpha)| alpha) } - - fn color_picker_uses_alpha(&self, id: ColorPickerId) -> bool { - matches!( - id, - ColorPickerId::StatusBarBg - | ColorPickerId::StatusBarText - | ColorPickerId::HighlightFill - | ColorPickerId::HighlightOutline - | ColorPickerId::HelpBg - | ColorPickerId::HelpBorder - | ColorPickerId::HelpText - | ColorPickerId::ExportPdfLabelText - | ColorPickerId::ExportPdfLabelBackground - ) - } } fn normalized_drawing_rgb(values: &[String; 3]) -> Option<[f64; 3]> { @@ -341,33 +312,11 @@ fn format_rgb255(value: f64) -> String { #[cfg(test)] mod tests { use super::*; - use crate::models::{ColorMode, ColorPickerValue}; - - #[test] - fn drawing_color_picker_writes_rgb255_components_and_hex() { - let (mut app, _cmd) = ConfiguratorApp::new_app(); - app.draft.drawing_color.mode = ColorMode::Rgb; - - let _ = app.handle_color_picker_changed( - ColorPickerId::DrawingColor, - ColorPickerValue { - rgb: [0.0, 0.5, 1.0], - alpha: None, - }, - ); - - assert_eq!(app.draft.drawing_color.rgb, ["0", "128", "255"]); - assert_eq!( - app.color_picker_hex - .get(&ColorPickerId::DrawingColor) - .map(String::as_str), - Some("#0080FF") - ); - } + use crate::models::ColorMode; #[test] fn drawing_color_picker_hex_writes_rgb255_components() { - let (mut app, _cmd) = ConfiguratorApp::new_app(); + let (mut app, _effects) = ConfiguratorApp::new_app(); app.draft.drawing_color.mode = ColorMode::Rgb; let _ = @@ -378,7 +327,7 @@ mod tests { #[test] fn quick_color_picker_hex_writes_slot_rgb255_components() { - let (mut app, _cmd) = ConfiguratorApp::new_app(); + let (mut app, _effects) = ConfiguratorApp::new_app(); app.draft.drawing_quick_colors.entries[1].color.mode = ColorMode::Rgb; let _ = app @@ -389,4 +338,18 @@ mod tests { ["18", "52", "86"] ); } + + #[test] + fn explicit_opaque_alpha_replaces_the_previous_translucency() { + let (mut app, _effects) = ConfiguratorApp::new_app(); + app.draft.help_bg_color.components[3] = "0.25".to_string(); + + let _ = app.handle_color_picker_hex_changed(ColorPickerId::HelpBg, "#123456FF".to_string()); + + assert_eq!(app.draft.help_bg_color.components[3], "1"); + assert_eq!( + app.color_picker_hex.get(&ColorPickerId::HelpBg), + Some(&"#123456FF".to_string()) + ); + } } diff --git a/configurator/src/app/update/config.rs b/configurator/src/app/update/config.rs index 5750a2a4..36285a46 100644 --- a/configurator/src/app/update/config.rs +++ b/configurator/src/app/update/config.rs @@ -1,35 +1,28 @@ -use std::path::PathBuf; -use std::sync::Arc; - -use iced::Task; use wayscriber::config::{ Config, ConfigDiagnosticKind, ConfigDocument, ConfigValidationReport, InvalidKeybinding, KeybindingConflictResolution, MigrationPreview, }; -use crate::messages::Message; +use crate::messages::ConfigSaveResult; use crate::models::error::FormError; use crate::models::{ConfigDraft, KeybindingField}; -use super::super::io::{load_config_from_disk, save_config_to_disk}; +use super::super::effects::Effect; use super::super::state::{ConfiguratorApp, StatusMessage}; impl ConfiguratorApp { pub(super) fn handle_config_loaded( &mut self, - result: Result<(Arc, Option), String>, - ) -> Task { + result: Result<(Box, Option), String>, + ) -> Vec { self.is_loading = false; match result { Ok((document, repair_warning)) => { let draft = ConfigDraft::from_config(document.config()); self.draft = draft.clone(); self.baseline = draft; - self.base_document = Some(document.clone()); self.override_mode = self.draft.ui_toolbar_layout_mode; self.boards_collapsed = vec![false; self.draft.boards.items.len()]; - self.color_picker_open = None; - self.color_picker_advanced.clear(); self.color_picker_hex.clear(); self.sync_all_color_picker_hex(); self.is_dirty = false; @@ -43,6 +36,9 @@ impl ConfiguratorApp { )) }, ); + // Last, so everything above reads the document by reference and + // the model takes ownership of exactly one copy. + self.base_document = Some(*document); } Err(err) => { self.status = @@ -57,74 +53,89 @@ impl ConfiguratorApp { self.apply_startup_request() } - pub(super) fn handle_reload_requested(&mut self) -> Task { + pub(super) fn handle_reload_requested(&mut self) -> Vec { if !self.is_loading && !self.is_saving { self.is_loading = true; self.defaults_reset_pending = false; self.status = StatusMessage::info("Reloading configuration..."); - return Task::perform(load_config_from_disk(), Message::ConfigLoaded); + return vec![Effect::LoadConfig]; } - Task::none() + Vec::new() } - pub(super) fn handle_reset_to_defaults_requested(&mut self) -> Task { - if !self.is_loading && !self.is_saving { + /// One button drives the two-step reset: the first press arms the + /// confirm (the shell relabels the button "Confirm reset?"), the second + /// press while armed applies the defaults. Any other edit disarms it + /// through `refresh_dirty_flag`, which is the old Cancel path. + pub(super) fn handle_reset_to_defaults_requested(&mut self) -> Vec { + if self.is_loading || self.is_saving { + return Vec::new(); + } + + if !self.defaults_reset_pending { self.defaults_reset_pending = true; self.status = StatusMessage::warning( - "Defaults will replace the current draft with built-in defaults. Press Confirm Defaults to continue.", + "Defaults will replace the current draft with built-in defaults. Press \"Confirm reset?\" to continue.", ); + return Vec::new(); } - Task::none() - } - - pub(super) fn handle_reset_to_defaults_canceled(&mut self) -> Task { + self.draft = self.defaults.clone(); + self.override_mode = self.draft.ui_toolbar_layout_mode; + self.boards_collapsed = vec![false; self.draft.boards.items.len()]; + self.color_picker_hex.clear(); + self.sync_all_color_picker_hex(); self.defaults_reset_pending = false; - self.status = StatusMessage::idle(); - Task::none() - } - - pub(super) fn handle_reset_to_defaults_confirmed(&mut self) -> Task { - if self.defaults_reset_pending && !self.is_loading && !self.is_saving { - self.draft = self.defaults.clone(); - self.override_mode = self.draft.ui_toolbar_layout_mode; - self.boards_collapsed = vec![false; self.draft.boards.items.len()]; - self.color_picker_open = None; - self.color_picker_advanced.clear(); - self.color_picker_hex.clear(); - self.sync_all_color_picker_hex(); - self.defaults_reset_pending = false; - self.status = StatusMessage::info("Loaded default configuration (not saved)."); - self.refresh_dirty_flag(); - } - - Task::none() + self.status = StatusMessage::info("Loaded default configuration (not saved)."); + self.refresh_dirty_flag(); + Vec::new() } - pub(super) fn handle_save_requested(&mut self) -> Task { + pub(super) fn handle_save_requested(&mut self) -> Vec { // `is_loading` counts too: a reload replaces the draft and base // document when it lands, so a save started underneath it would write // the pre-reload draft and then be judged against a document it never // saw — leaving stale fields marked clean and the next save rejected. if self.is_saving || self.is_loading { - return Task::none(); + return Vec::new(); } self.defaults_reset_pending = false; - let Some(document) = self.base_document.clone() else { + + // Before the document moves anywhere: a hex field the parser rejects + // was never applied to the draft, so saving would write the last value + // that did parse and the reload would wipe the text the user is still + // fixing. + let invalid_hex = self.invalid_color_hex_count(); + if invalid_hex > 0 { + self.status = StatusMessage::error(invalid_color_hex_message(invalid_hex)); + return Vec::new(); + } + + // The write needs the document itself, so the model gives up its only + // copy here and gets one back from `handle_config_saved` either way. + // Taking it is also the "nothing loaded" check: there is one `Option` + // to read, and reading it is what moves the value. + let Some(document) = self.base_document.take() else { self.status = StatusMessage::error( "Configuration has not loaded successfully. Reload before saving.", ); - return Task::none(); + return Vec::new(); }; match self.prepare_config_to_save(&document) { Ok(config) => { self.is_saving = true; self.status = StatusMessage::info("Saving configuration..."); - Task::perform(save_config_to_disk(document, config), Message::ConfigSaved) + vec![Effect::SaveConfig { + document: Box::new(document), + config: Box::new(config), + }] } Err(errors) => { + // No write starts, so the document goes straight back: this + // handler must not be a way to lose it. + self.base_document = Some(document); let message = errors .into_iter() .map(|err| format!("{}: {}", err.field, err.message)) @@ -133,7 +144,7 @@ impl ConfiguratorApp { self.status = StatusMessage::error(format!( "Cannot save due to validation errors:\n{message}" )); - Task::none() + Vec::new() } } } @@ -156,10 +167,7 @@ impl ConfiguratorApp { Ok(config) } - pub(super) fn handle_config_saved( - &mut self, - result: Result<(Option, Arc), String>, - ) -> Task { + pub(super) fn handle_config_saved(&mut self, result: ConfigSaveResult) -> Vec { self.is_saving = false; // Either outcome answers this write; a failed one wrote nothing, so // there is no resolution to report for it. @@ -170,10 +178,7 @@ impl ConfiguratorApp { self.last_backup_path = backup.clone(); self.draft = draft.clone(); self.baseline = draft; - self.base_document = Some(saved_document.clone()); self.boards_collapsed = vec![false; self.draft.boards.items.len()]; - self.color_picker_open = None; - self.color_picker_advanced.clear(); self.color_picker_hex.clear(); self.sync_all_color_picker_hex(); self.is_dirty = false; @@ -191,13 +196,26 @@ impl ConfiguratorApp { status = status.with_note(¬e); } self.status = status; + self.base_document = Some(*saved_document); } - Err(err) => { - self.status = StatusMessage::error(format!("Failed to save configuration: {err}")); + Err((document, err)) => { + // The write borrowed the model's only document; a failure hands + // it straight back so the draft stays savable. The one case + // with nothing to hand back is a blocking job that never + // returned, which leaves a reload as the way forward. + let restored = document.is_some(); + self.base_document = document.map(|document| *document); + let mut message = format!("Failed to save configuration: {err}"); + if !restored { + message.push_str( + "\nThe loaded configuration did not come back from the failed write. Reload before saving again.", + ); + } + self.status = StatusMessage::error(message); } } - Task::none() + Vec::new() } /// Recomputes what a migration would propose for the document now in hand. @@ -220,12 +238,12 @@ impl ConfiguratorApp { self.migration_preview = MigrationPreview::for_authored_config(document.authored_config()); } - pub(super) fn handle_migration_apply_requested(&mut self) -> Task { + pub(super) fn handle_migration_apply_requested(&mut self) -> Vec { if self.is_loading || self.is_saving { - return Task::none(); + return Vec::new(); } let Some(preview) = self.pending_migration().cloned() else { - return Task::none(); + return Vec::new(); }; let mut applied = 0usize; @@ -272,29 +290,44 @@ impl ConfiguratorApp { self.status = StatusMessage::info(message); self.refresh_dirty_flag(); - Task::none() + Vec::new() } - pub(super) fn handle_migration_dismissed(&mut self) -> Task { + pub(super) fn handle_migration_dismissed(&mut self) -> Vec { // Left silent on purpose: the status banner may be carrying the load // diagnostics for this file, and hiding the offer is not worth losing // them over. // // Recorded against the file the offer was about, not the path that // reached it: only a reload landing on that same file is the reload - // this answer covers. With no document loaded there is no offer on - // screen and nothing to answer. - self.migration_dismissed = self - .base_document - .as_ref() - .map(|document| document.destination().to_path_buf()); + // this answer covers. Without a document in hand there is no file to + // name — no load has produced one, or a running save is holding it — + // and an answer already given stands rather than being cleared. + if let Some(document) = self.base_document.as_ref() { + self.migration_dismissed = Some(document.destination().to_path_buf()); + } - Task::none() + Vec::new() } } const SHOWN_DIAGNOSTICS: usize = 8; +/// Why a save was refused before it began. +/// +/// The count is all the banner can give: which rows are at fault is the row's +/// own job to show, and the fix is the same for every one of them — type a +/// color. Clearing the field is not a way out: the picker edits a color the +/// config requires, so an empty field is refused like any other text that is +/// not a color. +fn invalid_color_hex_message(count: usize) -> String { + if count == 1 { + return "1 color field does not hold a color. Enter #RRGGBB or #RRGGBBAA before saving." + .to_string(); + } + format!("{count} color fields do not hold a color. Enter #RRGGBB or #RRGGBBAA before saving.") +} + fn config_document_status(document: &ConfigDocument, success: &str) -> StatusMessage { let diagnostics = document.diagnostics(); if diagnostics.is_empty() { @@ -430,10 +463,42 @@ fn list_with_overflow(entries: &[&str], separator: &str) -> String { } } +/// The migration offer as the banner shows it, with its whole change list in +/// view. +/// +/// The list is not behind a Review button: a recipe proposes at most a handful +/// of shortcuts, and putting Apply next to something the user has not read yet +/// is the one thing this flow exists to avoid. +pub(crate) fn migration_offer_text(preview: &MigrationPreview) -> String { + let mut lines = vec![ + "Configuration update available".to_string(), + format!( + "Shortcut defaults changed since this configuration was written. Applying updates this draft only; nothing reaches the file until you press Save, which also records revision {}.", + preview.proposed_revision() + ), + ]; + for change in preview.changes() { + lines.push(format!( + "{} ({}): {} → {}", + change.action_label(), + change.config_key(), + binding_summary(change.before()), + binding_summary(change.after()), + )); + } + lines.join("\n") +} + +fn binding_summary(bindings: &[String]) -> String { + if bindings.is_empty() { + return "unbound".to_string(); + } + bindings.join(", ") +} + #[cfg(test)] mod tests { use std::path::{Path, PathBuf}; - use std::sync::Arc; use std::sync::atomic::{AtomicU64, Ordering}; use wayscriber::config::{Action, CURRENT_CONFIG_REVISION}; @@ -454,7 +519,7 @@ mod tests { static TEMP_SEQUENCE: AtomicU64 = AtomicU64::new(0); - fn temp_config_document(name: &str, contents: &str) -> (PathBuf, Arc) { + fn temp_config_document(name: &str, contents: &str) -> (PathBuf, Box) { let sequence = TEMP_SEQUENCE.fetch_add(1, Ordering::Relaxed); let path = std::env::temp_dir().join(format!( "wayscriber-configurator-update-config-{}-{sequence}-{name}.toml", @@ -462,13 +527,12 @@ mod tests { )); std::fs::write(&path, contents).expect("write test config"); let document = ConfigDocument::load_from_path(&path).expect("load test config document"); - (path, Arc::new(document)) + (path, Box::new(document)) } #[test] fn handle_config_loaded_success_resets_loading_and_dirty_state() { - let (mut app, _cmd) = ConfiguratorApp::new_app(); - app.color_picker_open = Some(ColorPickerId::StatusBarBg); + let (mut app, _effects) = ConfiguratorApp::new_app(); app.is_dirty = true; let (path, document) = temp_config_document("loaded", ""); @@ -476,7 +540,6 @@ mod tests { assert!(!app.is_loading); assert!(!app.is_dirty); - assert!(app.color_picker_open.is_none()); assert_eq!(app.boards_collapsed.len(), app.draft.boards.items.len()); assert!(status_contains( &app.status, @@ -487,38 +550,44 @@ mod tests { #[test] fn handle_config_loaded_uses_startup_search_focus_fallback_once() { - let (mut app, _cmd) = ConfiguratorApp::new_app(); + let (mut app, _effects) = ConfiguratorApp::new_app(); let (first_path, first) = temp_config_document("focus-first", ""); let _ = app.handle_config_loaded(Ok((first, None))); - assert!(app.search_input_focus_hint); + assert_eq!(app.search_focus_serial, 1); assert!(!app.startup_search_focus_pending); - app.search_input_focus_hint = false; + // A reload is not a relaunch: the offer was answered by the first load, + // so the caret stays wherever the user put it. let (second_path, second) = temp_config_document("focus-second", ""); let _ = app.handle_config_loaded(Ok((second, None))); - assert!(!app.search_input_focus_hint); + assert_eq!(app.search_focus_serial, 1); let _ = std::fs::remove_file(first_path); let _ = std::fs::remove_file(second_path); } #[test] fn handle_config_loaded_error_preserves_the_last_good_document_and_draft() { - let (mut app, _cmd) = ConfiguratorApp::new_app(); + let (mut app, _effects) = ConfiguratorApp::new_app(); let (path, document) = temp_config_document("before-reload-error", ""); - let _ = app.handle_config_loaded(Ok((document.clone(), None))); + let destination = document.destination().to_path_buf(); + let _ = app.handle_config_loaded(Ok((document, None))); app.draft.capture_enabled = !app.draft.capture_enabled; let draft = app.draft.clone(); let _ = app.handle_config_loaded(Err("broken".to_string())); assert!(!app.is_loading); - assert!(Arc::ptr_eq( - app.base_document.as_ref().expect("last good document"), - &document - )); + assert_eq!( + app.base_document + .as_ref() + .expect("last good document") + .destination(), + destination, + "a failed reload keeps the document the last good load produced" + ); assert_eq!(app.draft, draft); assert!(status_contains( &app.status, @@ -529,7 +598,7 @@ mod tests { #[test] fn handle_config_loaded_repair_document_allows_saving() { - let (mut app, _cmd) = ConfiguratorApp::new_app(); + let (mut app, _effects) = ConfiguratorApp::new_app(); let (path, document) = temp_config_document("repair", ""); let _ = app.handle_config_loaded(Ok(( @@ -551,7 +620,7 @@ mod tests { #[test] fn handle_config_loaded_surfaces_preserved_unknown_settings() { - let (mut app, _cmd) = ConfiguratorApp::new_app(); + let (mut app, _effects) = ConfiguratorApp::new_app(); let (path, document) = temp_config_document("unknown", "future_configurator_option = true\n"); @@ -568,7 +637,7 @@ mod tests { /// spelled out in the file, which is what makes it their conflict. #[test] fn handle_config_loaded_surfaces_resolved_shortcut_conflicts() { - let (mut app, _cmd) = ConfiguratorApp::new_app(); + let (mut app, _effects) = ConfiguratorApp::new_app(); let (path, document) = temp_config_document( "shortcut-conflict", &format!( @@ -596,7 +665,7 @@ mod tests { /// about in the release notes simply is not theirs (#293). #[test] fn handle_config_loaded_surfaces_skipped_default_shortcuts() { - let (mut app, _cmd) = ConfiguratorApp::new_app(); + let (mut app, _effects) = ConfiguratorApp::new_app(); let (path, document) = temp_config_document( "skipped-default", &format!( @@ -627,7 +696,7 @@ mod tests { /// nothing else wrong in the file, this section is the entire warning. #[test] fn handle_config_loaded_surfaces_shortcuts_that_could_not_be_parsed() { - let (mut app, _cmd) = ConfiguratorApp::new_app(); + let (mut app, _effects) = ConfiguratorApp::new_app(); let (path, document) = temp_config_document( "invalid-shortcut", &format!( @@ -654,7 +723,7 @@ mod tests { /// sentence: they need different fixes, and one of them needs no fix. #[test] fn handle_config_loaded_separates_every_keybinding_diagnostic_kind() { - let (mut app, _cmd) = ConfiguratorApp::new_app(); + let (mut app, _effects) = ConfiguratorApp::new_app(); let (path, document) = temp_config_document( "invalid-and-conflicting", &format!( @@ -680,13 +749,14 @@ mod tests { #[test] fn handle_save_requested_blocks_without_loaded_document() { - let (mut app, _cmd) = ConfiguratorApp::new_app(); + let (mut app, _effects) = ConfiguratorApp::new_app(); // A fresh app is still running its startup load; this test is about // the load having finished without producing a document. app.is_loading = false; - let _ = app.handle_save_requested(); + let effects = app.handle_save_requested(); + assert!(effects.is_empty()); assert!(!app.is_saving); assert!(status_contains( &app.status, @@ -696,21 +766,232 @@ mod tests { #[test] fn handle_save_requested_sets_saving_for_valid_draft() { - let (mut app, _cmd) = ConfiguratorApp::new_app(); + let (mut app, _effects) = ConfiguratorApp::new_app(); app.is_saving = false; let (path, document) = temp_config_document("save-request", ""); let _ = app.handle_config_loaded(Ok((document, None))); - let _ = app.handle_save_requested(); + let effects = app.handle_save_requested(); + assert!(matches!(effects.as_slice(), [Effect::SaveConfig { .. }])); assert!(app.is_saving); assert!(status_contains(&app.status, "Saving configuration...")); let _ = std::fs::remove_file(path); } + /// The document is the model's only copy, and the write needs it moved. It + /// therefore leaves while the write runs and the result brings one back — + /// the saved document on success. + #[test] + fn the_running_save_holds_the_document_and_the_result_returns_one() { + let (mut app, _dir, _path) = app_with_config_file(""); + + let (document, config) = save_effect(&mut app); + + assert!( + app.base_document.is_none(), + "the write holds the document while it runs" + ); + let (saved, backup) = document + .save_with_backup(*config) + .expect("the document saves") + .into_parts(); + let _ = app.handle_config_saved(Ok((backup, Box::new(saved)))); + + assert!( + app.base_document.is_some(), + "a finished save hands a document back" + ); + } + + /// A write that failed wrote nothing, so the document it borrowed is still + /// the one the editor is against: it comes back, and the next Save works. + #[test] + fn a_failed_save_hands_the_document_back() { + let (mut app, _dir, _path) = app_with_config_file(""); + app.draft.drawing_default_thickness = "6".to_string(); + app.refresh_dirty_flag(); + + let (document, _config) = save_effect(&mut app); + assert!(app.base_document.is_none()); + + let _ = app.handle_config_saved(Err((Some(document), "Permission denied".to_string()))); + + assert!(!app.is_saving); + assert!( + app.base_document.is_some(), + "the document the failed write borrowed must return to the model" + ); + assert!(app.is_dirty, "the draft is still unsaved"); + assert!(status_contains( + &app.status, + "Failed to save configuration: Permission denied" + )); + assert!( + !status_contains(&app.status, "Reload before saving again"), + "the document came back, so there is nothing to reload for: {:?}", + app.status + ); + + // The proof that it came back whole: the very next Save is accepted. + let effects = app.handle_save_requested(); + assert!(matches!(effects.as_slice(), [Effect::SaveConfig { .. }])); + } + + /// The one failure with nothing to hand back is a blocking job that never + /// returned. Saving again cannot work until a reload produces a document, + /// so the status has to say so. + #[test] + fn a_save_whose_job_never_returned_asks_for_a_reload() { + let (mut app, _dir, _path) = app_with_config_file(""); + let (_document, _config) = save_effect(&mut app); + + let _ = + app.handle_config_saved(Err((None, "config save blocking job panicked".to_string()))); + + assert!(app.base_document.is_none()); + assert!(status_contains(&app.status, "Reload before saving again")); + } + + /// A draft the converter rejects never reaches a write, so the document + /// must be back in the model by the time the handler returns. + #[test] + fn a_draft_the_converter_rejects_keeps_the_document() { + let (mut app, _dir, _path) = app_with_config_file(""); + app.draft.drawing_default_thickness = "thick".to_string(); + + let effects = app.handle_save_requested(); + + assert!(effects.is_empty()); + assert!(!app.is_saving); + assert!( + app.base_document.is_some(), + "a refused save must not take the document with it" + ); + assert!(status_contains( + &app.status, + "Cannot save due to validation" + )); + } + + /// Hex text the parser rejects was never applied to the draft, so a save + /// would write the last value that did parse and the reload would replace + /// the text with it. The Save is refused instead. + #[test] + fn a_color_field_holding_invalid_hex_blocks_the_save() { + let (mut app, _dir, _path) = app_with_config_file(""); + app.draft.drawing_default_thickness = "6".to_string(); + app.refresh_dirty_flag(); + let _ = app.handle_color_picker_hex_changed(ColorPickerId::HelpBg, "#12zz".to_string()); + + assert_eq!(app.invalid_color_hex_count(), 1); + + let effects = app.handle_save_requested(); + + assert!(effects.is_empty()); + assert!(!app.is_saving); + assert!(app.base_document.is_some(), "nothing was written or taken"); + assert!(status_contains(&app.status, "1 color field")); + assert!(status_contains( + &app.status, + "Enter #RRGGBB or #RRGGBBAA before saving" + )); + } + + /// The one way out of the refusal: type a color that parses. + #[test] + fn correcting_the_color_field_allows_the_save_again() { + let (mut app, _dir, _path) = app_with_config_file(""); + app.draft.drawing_default_thickness = "6".to_string(); + app.refresh_dirty_flag(); + let _ = app.handle_color_picker_hex_changed(ColorPickerId::HelpBg, "#12zz".to_string()); + assert!(app.handle_save_requested().is_empty()); + + let _ = app.handle_color_picker_hex_changed(ColorPickerId::HelpBg, "#102030".to_string()); + + assert_eq!(app.invalid_color_hex_count(), 0); + assert!(matches!( + app.handle_save_requested().as_slice(), + [Effect::SaveConfig { .. }] + )); + } + + /// Clearing is not a way out. The picker edits a color the config + /// requires, so an empty field is an edit the save cannot write: letting + /// it through would keep the previous color and put it straight back in + /// the field on the next reload. + #[test] + fn clearing_the_color_field_keeps_the_save_blocked() { + for cleared in ["", " "] { + let (mut app, _dir, _path) = app_with_config_file(""); + app.draft.drawing_default_thickness = "6".to_string(); + app.refresh_dirty_flag(); + + let _ = app.handle_color_picker_hex_changed(ColorPickerId::HelpBg, cleared.to_string()); + + assert_eq!(app.invalid_color_hex_count(), 1, "{cleared:?}"); + let effects = app.handle_save_requested(); + assert!(effects.is_empty(), "{cleared:?} must not reach a save"); + assert!(status_contains(&app.status, "1 color field")); + } + } + + /// Deleting the row a refused color was in has to release the save with + /// it: the field is gone from the screen, so nothing is left to fix. + #[test] + fn removing_a_quick_color_releases_the_save_its_hex_had_refused() { + let (mut app, _dir, _path) = app_with_config_file(""); + let _ = app.handle_quick_color_added(); + let last = app.draft.drawing_quick_colors.entries.len() - 1; + let _ = app + .handle_color_picker_hex_changed(ColorPickerId::QuickColor(last), "#12zz".to_string()); + assert!(app.handle_save_requested().is_empty()); + + let _ = app.handle_quick_color_removed(last); + + assert_eq!(app.invalid_color_hex_count(), 0); + assert!(matches!( + app.handle_save_requested().as_slice(), + [Effect::SaveConfig { .. }] + )); + } + + /// The transient the empty rule could have wedged: editing a component + /// resyncs that picker's hex, so a normal edit never leaves the field + /// blank and the save gate never closes behind the user's back. + #[test] + fn applying_a_color_leaves_the_field_holding_that_color() { + let (mut app, _dir, _path) = app_with_config_file(""); + let _ = app.handle_color_picker_hex_changed(ColorPickerId::HelpBg, String::new()); + assert_eq!(app.invalid_color_hex_count(), 1); + + let _ = app.handle_color_picker_hex_changed(ColorPickerId::HelpBg, "#102030".to_string()); + + assert_eq!(app.invalid_color_hex_count(), 0); + } + + /// Several bad fields are one refusal, and the count is what tells the user + /// how much is left to fix. + #[test] + fn every_invalid_color_field_is_counted_for_the_refusal() { + let (mut app, _dir, _path) = app_with_config_file(""); + let _ = app.handle_color_picker_hex_changed(ColorPickerId::HelpBg, "#12zz".to_string()); + let _ = app.handle_color_picker_hex_changed(ColorPickerId::HelpText, "nope".to_string()); + + assert_eq!(app.invalid_color_hex_count(), 2); + + let _ = app.handle_save_requested(); + + assert!(status_contains(&app.status, "2 color fields")); + assert!(status_contains( + &app.status, + "Enter #RRGGBB or #RRGGBBAA before saving" + )); + } + #[test] fn reset_to_defaults_requires_confirmation() { - let (mut app, _cmd) = ConfiguratorApp::new_app(); + let (mut app, _effects) = ConfiguratorApp::new_app(); app.is_loading = false; app.draft.capture_enabled = !app.defaults.capture_enabled; let changed_draft = app.draft.clone(); @@ -719,31 +1000,40 @@ mod tests { assert!(app.defaults_reset_pending); assert_eq!(app.draft, changed_draft); - assert!(status_contains(&app.status, "Confirm Defaults")); + assert!(status_contains(&app.status, "Confirm reset?")); + } - let _ = app.handle_reset_to_defaults_confirmed(); + /// The same button applies the reset once the confirm is armed — the + /// wiring gap where "Confirm reset?" could never fire. + #[test] + fn reset_to_defaults_second_press_applies_the_defaults() { + let (mut app, _effects) = ConfiguratorApp::new_app(); + app.is_loading = false; + app.draft.capture_enabled = !app.defaults.capture_enabled; + app.baseline.capture_enabled = !app.defaults.capture_enabled; + + let _ = app.handle_reset_to_defaults_requested(); + let _ = app.handle_reset_to_defaults_requested(); - assert!(!app.defaults_reset_pending); assert_eq!(app.draft, app.defaults); + assert!(!app.defaults_reset_pending); assert!(status_contains(&app.status, "Loaded default configuration")); + assert!( + app.is_dirty, + "defaults differing from the loaded baseline must read as dirty" + ); } #[test] fn reset_to_defaults_confirmation_is_canceled_by_draft_edit() { - let (mut app, _cmd) = ConfiguratorApp::new_app(); + let (mut app, _effects) = ConfiguratorApp::new_app(); app.is_loading = false; let _ = app.handle_reset_to_defaults_requested(); let _ = app.handle_toggle_changed(ToggleField::CaptureEnabled, !app.draft.capture_enabled); - let edited_draft = app.draft.clone(); assert!(!app.defaults_reset_pending); assert!(matches!(app.status, StatusMessage::Idle)); - - let _ = app.handle_reset_to_defaults_confirmed(); - - assert_eq!(app.draft, edited_draft); - assert_ne!(app.draft, app.defaults); } /// The reviewer's case: the file spells `undo` out and never mentions @@ -762,7 +1052,7 @@ mod tests { .keybindings .set(KeybindingField::ClearCanvas, "Ctrl+Alt+U".to_string()); - let document = app.base_document.clone().expect("a loaded document"); + let document = app.base_document.as_ref().expect("a loaded document"); let mut config = app .draft .to_config(document.config()) @@ -862,9 +1152,9 @@ mod tests { /// judged against a document it never saw. #[test] fn save_is_refused_while_a_reload_is_in_flight() { - let (mut app, _cmd) = ConfiguratorApp::new_app(); + let (mut app, _effects) = ConfiguratorApp::new_app(); let (path, document) = temp_config_document("save-during-reload", ""); - app.base_document = Some(document); + app.base_document = Some(*document); app.is_loading = true; app.is_dirty = true; let before = app.status.clone(); @@ -886,7 +1176,7 @@ mod tests { #[test] fn handle_config_saved_success_clears_dirty_and_records_backup() { - let (mut app, _cmd) = ConfiguratorApp::new_app(); + let (mut app, _effects) = ConfiguratorApp::new_app(); app.is_saving = true; app.is_dirty = true; app.draft.capture_enabled = !app.draft.capture_enabled; @@ -894,6 +1184,7 @@ mod tests { let (path, document) = temp_config_document("saved", ""); let _ = app.handle_config_saved(Ok((Some(backup.clone()), document))); + assert!(app.base_document.is_some()); assert!(!app.is_saving); assert!(!app.is_dirty); @@ -914,32 +1205,39 @@ mod tests { let dir = crate::test_temp::tempdir().expect("temporary test directory"); let path = dir.path().join("config.toml"); std::fs::write(&path, contents).expect("write test config"); - let (mut app, _cmd) = ConfiguratorApp::new_app(); + let (mut app, _effects) = ConfiguratorApp::new_app(); load_config_file(&mut app, &path); (app, dir, path) } fn load_config_file(app: &mut ConfiguratorApp, path: &Path) { let document = ConfigDocument::load_from_path(path).expect("load test config document"); - let _ = app.handle_config_loaded(Ok((Arc::new(document), None))); + let _ = app.handle_config_loaded(Ok((Box::new(document), None))); } - /// The Save path with the executor left out: `handle_save_requested` - /// builds exactly this config, and `save_config_to_disk` performs exactly - /// this write before handing the outcome back to `handle_config_saved`. + /// The Save path with the executor left out: the handler produces exactly + /// this effect, and `save_config_to_disk` performs exactly this write + /// before handing the outcome back to `handle_config_saved`. fn save_draft(app: &mut ConfiguratorApp) -> Option { - let document = app.base_document.clone().expect("a loaded document"); - let config = app - .prepare_config_to_save(&document) - .expect("the draft converts to a config"); + let (document, config) = save_effect(app); let (saved, backup) = document - .save_with_backup(config) + .save_with_backup(*config) .expect("the document saves") .into_parts(); - let _ = app.handle_config_saved(Ok((backup.clone(), Arc::new(saved)))); + let _ = app.handle_config_saved(Ok((backup.clone(), Box::new(saved)))); backup } + /// The write a Save asked for, unpacked. + fn save_effect(app: &mut ConfiguratorApp) -> (Box, Box) { + let mut effects = app.handle_save_requested(); + assert_eq!(effects.len(), 1, "a Save asks for exactly one write"); + match effects.remove(0) { + Effect::SaveConfig { document, config } => (document, config), + other => panic!("a Save must ask for a write, not {other:?}"), + } + } + /// One setting exactly as the saved file spells it, with the line wrapping /// the merge may choose for an array folded into single spaces. fn config_setting(contents: &str, key: &str) -> Option { @@ -1048,6 +1346,31 @@ mod tests { ); } + /// The banner is the only place the user reads what Apply would do, so it + /// has to name every proposed change as before → after. + #[test] + fn the_migration_offer_text_lists_every_proposed_change() { + let (app, _dir, _path) = app_with_config_file(LEGACY_REVISION_ZERO_CONFIG); + let preview = app.pending_migration().expect("the fixture is out of date"); + + let text = migration_offer_text(preview); + + let mut lines = text.lines(); + assert_eq!(lines.next(), Some("Configuration update available")); + assert!( + lines + .next() + .is_some_and(|line| line.contains("nothing reaches the file until you press Save")), + "{text}" + ); + let changes = lines.collect::>(); + assert_eq!(changes.len(), preview.changes().len()); + for (line, change) in changes.iter().zip(preview.changes()) { + assert!(line.contains(change.config_key()), "{line}"); + assert!(line.contains(" → "), "{line}"); + } + } + /// Dismissing answers the question for this app run. A reload recomputes /// the preview, but the user already said no to this file. #[test] @@ -1271,7 +1594,7 @@ mod tests { let link = dir.path().join("config.toml"); symlink(&first, &link).expect("link the config path at the first profile"); - let (mut app, _cmd) = ConfiguratorApp::new_app(); + let (mut app, _effects) = ConfiguratorApp::new_app(); load_config_file(&mut app, &link); assert!(app.pending_migration().is_some()); @@ -1312,7 +1635,7 @@ mod tests { assert!(app.pending_migration().is_none()); let dir = crate::test_temp::tempdir().expect("temporary test directory"); - let (mut app, _cmd) = ConfiguratorApp::new_app(); + let (mut app, _effects) = ConfiguratorApp::new_app(); load_config_file(&mut app, &dir.path().join("missing.toml")); assert!(app.pending_migration().is_none()); diff --git a/configurator/src/app/update/daemon.rs b/configurator/src/app/update/daemon.rs index fed829d5..bfbf3ee2 100644 --- a/configurator/src/app/update/daemon.rs +++ b/configurator/src/app/update/daemon.rs @@ -1,9 +1,6 @@ -use iced::Task; - -use crate::messages::Message; use crate::models::{DaemonAction, DaemonActionResult, DaemonRuntimeStatus}; -use super::super::daemon_setup::{load_daemon_runtime_status, perform_daemon_action}; +use super::super::effects::Effect; use super::super::state::ConfiguratorApp; impl ConfiguratorApp { @@ -11,9 +8,9 @@ impl ConfiguratorApp { &mut self, request_id: u64, result: Result, - ) -> Task { + ) -> Vec { if request_id != self.daemon_latest_status_request_id { - return Task::none(); + return Vec::new(); } let preserve_feedback = self.daemon_preserve_feedback_status_request_id == Some(request_id); if preserve_feedback { @@ -44,38 +41,38 @@ impl ConfiguratorApp { } } } - Task::none() + Vec::new() } - pub(super) fn handle_daemon_shortcut_input_changed(&mut self, value: String) -> Task { + pub(super) fn handle_daemon_shortcut_input_changed(&mut self, value: String) -> Vec { self.daemon_shortcut_input = value; - Task::none() + Vec::new() } - pub(super) fn handle_daemon_action_requested(&mut self, action: DaemonAction) -> Task { + pub(super) fn handle_daemon_action_requested(&mut self, action: DaemonAction) -> Vec { if self.daemon_busy { - return Task::none(); + return Vec::new(); } self.invalidate_pending_daemon_status_requests(); self.daemon_busy = true; self.daemon_feedback = Some(action_pending_message(action)); let shortcut_input = self.daemon_shortcut_input.clone(); - Task::perform( - perform_daemon_action(action, shortcut_input), - Message::DaemonActionCompleted, - ) + vec![Effect::PerformDaemonAction { + action, + shortcut_input, + }] } pub(super) fn handle_daemon_action_completed( &mut self, result: Result, - ) -> Task { + ) -> Vec { self.daemon_busy = false; match result { Ok(output) => { self.apply_daemon_status(output.status); self.daemon_feedback = Some(output.message); - Task::none() + Vec::new() } Err(err) => { self.daemon_feedback = Some(format!("Background setup action failed: {err}")); @@ -93,16 +90,14 @@ impl ConfiguratorApp { self.daemon_status = Some(status); } - fn schedule_daemon_status_reload(&mut self, preserve_feedback: bool) -> Task { + fn schedule_daemon_status_reload(&mut self, preserve_feedback: bool) -> Vec { let request_id = self.daemon_next_status_request_id; self.daemon_next_status_request_id = self.daemon_next_status_request_id.saturating_add(1); self.daemon_latest_status_request_id = request_id; if preserve_feedback { self.daemon_preserve_feedback_status_request_id = Some(request_id); } - Task::perform(load_daemon_runtime_status(), move |result| { - Message::DaemonStatusLoaded(request_id, result) - }) + vec![Effect::LoadDaemonStatus { request_id }] } fn invalidate_pending_daemon_status_requests(&mut self) { @@ -182,7 +177,7 @@ mod tests { #[test] fn daemon_status_loaded_sets_default_shortcut_when_missing() { - let (mut app, _command) = ConfiguratorApp::new_app(); + let (mut app, _effects) = ConfiguratorApp::new_app(); app.daemon_shortcut_input.clear(); let status = test_status(DesktopEnvironment::Kde, None); @@ -195,7 +190,7 @@ mod tests { #[test] fn daemon_action_completion_error_sets_feedback() { - let (mut app, _command) = ConfiguratorApp::new_app(); + let (mut app, _effects) = ConfiguratorApp::new_app(); let _ = app.handle_daemon_action_completed(Err("boom".to_string())); assert!( app.daemon_feedback @@ -211,7 +206,7 @@ mod tests { #[test] fn status_loaded_does_not_clear_daemon_busy() { - let (mut app, _command) = ConfiguratorApp::new_app(); + let (mut app, _effects) = ConfiguratorApp::new_app(); app.daemon_busy = true; app.daemon_feedback = Some("Installing/updating background service...".to_string()); let status = test_status(DesktopEnvironment::Kde, None); @@ -228,7 +223,7 @@ mod tests { #[test] fn failed_action_feedback_is_preserved_after_status_reload() { - let (mut app, _command) = ConfiguratorApp::new_app(); + let (mut app, _effects) = ConfiguratorApp::new_app(); let _ = app.handle_daemon_action_completed(Err("boom".to_string())); let preserved_request_id = app.daemon_latest_status_request_id; let status = test_status(DesktopEnvironment::Kde, None); @@ -246,7 +241,7 @@ mod tests { #[test] fn stale_status_callback_does_not_consume_preserve_flag() { - let (mut app, _command) = ConfiguratorApp::new_app(); + let (mut app, _effects) = ConfiguratorApp::new_app(); let _ = app.handle_daemon_action_completed(Err("boom".to_string())); let preserved_request_id = app.daemon_latest_status_request_id; let stale_request_id = preserved_request_id.saturating_sub(1); @@ -261,7 +256,7 @@ mod tests { #[test] fn preserved_error_is_not_applied_while_new_action_is_busy() { - let (mut app, _command) = ConfiguratorApp::new_app(); + let (mut app, _effects) = ConfiguratorApp::new_app(); let _ = app.handle_daemon_action_completed(Err("boom".to_string())); let preserved_request_id = app.daemon_latest_status_request_id; app.daemon_busy = true; @@ -280,7 +275,7 @@ mod tests { #[test] fn old_status_callback_after_newer_action_success_is_ignored() { - let (mut app, _command) = ConfiguratorApp::new_app(); + let (mut app, _effects) = ConfiguratorApp::new_app(); let old_status = test_status( DesktopEnvironment::Kde, Some("old".to_string()), @@ -297,7 +292,14 @@ mod tests { let _ = app.handle_daemon_action_completed(Err("old failure".to_string())); let old_request_id = app.daemon_latest_status_request_id; - let _ = app.handle_daemon_action_requested(DaemonAction::RefreshStatus); + let effects = app.handle_daemon_action_requested(DaemonAction::RefreshStatus); + assert!(matches!( + effects.as_slice(), + [Effect::PerformDaemonAction { + action: DaemonAction::RefreshStatus, + .. + }] + )); let _ = app.handle_daemon_action_completed(Ok(DaemonActionResult { status: new_status.clone(), message: "refresh complete".to_string(), diff --git a/configurator/src/app/update/fields.rs b/configurator/src/app/update/fields.rs index c61a1a21..7d30586f 100644 --- a/configurator/src/app/update/fields.rs +++ b/configurator/src/app/update/fields.rs @@ -1,72 +1,37 @@ -use iced::Task; use wayscriber::config::{PerformanceFieldId, ToolbarItemId, ToolbarItemOrderGroup}; -use crate::messages::Message; use crate::models::{ ColorMode, ColorPickerId, DragColorOption, DragMouseButton, DragToolField, DragToolOption, EraserModeOption, FontStyleOption, FontWeightOption, InputHudModeOption, InputHudPositionOption, KeybindingField, NamedColorOption, OverrideOption, PdfFitModeOption, PdfLabelContentModeOption, PdfLabelPositionOption, PdfOrientationOption, PdfPageSizeOption, PdfTransparentBackgroundOption, PresenterToolBehaviorOption, PresenterToolbarModeOption, - QuadField, ReducedMotionOption, SessionCompressionOption, SessionStorageModeOption, - StatusPositionOption, TextField, ToggleField, ToolbarLayoutModeOption, ToolbarOverrideField, - ToolbarRebindModifierOption, ToolbarSideLayoutOption, TripletField, UiThemeOption, - ZoomChipDisplayOption, + ReducedMotionOption, SessionCompressionOption, SessionStorageModeOption, StatusPositionOption, + TextField, ToggleField, ToolbarLayoutModeOption, ToolbarOverrideField, + ToolbarRebindModifierOption, ToolbarSideLayoutOption, UiThemeOption, ZoomChipDisplayOption, }; #[cfg(feature = "tablet-input")] use crate::models::{PressureThicknessEditModeOption, PressureThicknessEntryModeOption}; +use super::super::effects::Effect; use super::super::state::{ConfiguratorApp, StatusMessage}; impl ConfiguratorApp { - pub(super) fn handle_toggle_changed( - &mut self, - field: ToggleField, - value: bool, - ) -> Task { + pub(super) fn handle_toggle_changed(&mut self, field: ToggleField, value: bool) -> Vec { self.status = StatusMessage::idle(); self.draft.set_toggle(field, value); self.refresh_dirty_flag(); - Task::none() + Vec::new() } - pub(super) fn handle_text_changed(&mut self, field: TextField, value: String) -> Task { + pub(super) fn handle_text_changed(&mut self, field: TextField, value: String) -> Vec { self.status = StatusMessage::idle(); self.draft.set_text(field, value); self.refresh_dirty_flag(); - Task::none() - } - - pub(super) fn handle_triplet_changed( - &mut self, - field: TripletField, - index: usize, - value: String, - ) -> Task { - self.status = StatusMessage::idle(); - self.draft.set_triplet(field, index, value); - let id = triplet_field_picker_id(field); - self.sync_color_picker_hex_for_id(id); - self.refresh_dirty_flag(); - Task::none() - } - - pub(super) fn handle_quad_changed( - &mut self, - field: QuadField, - index: usize, - value: String, - ) -> Task { - self.status = StatusMessage::idle(); - self.draft.set_quad(field, index, value); - if let Some(id) = quad_field_picker_id(field) { - self.sync_color_picker_hex_for_id(id); - } - self.refresh_dirty_flag(); - Task::none() + Vec::new() } - pub(super) fn handle_color_mode_changed(&mut self, mode: ColorMode) -> Task { + pub(super) fn handle_color_mode_changed(&mut self, mode: ColorMode) -> Vec { self.status = StatusMessage::idle(); if matches!(mode, ColorMode::Rgb) { self.draft.drawing_color.sync_rgb_from_preview(); @@ -87,30 +52,27 @@ impl ConfiguratorApp { } self.sync_color_picker_hex_for_id(ColorPickerId::DrawingColor); self.refresh_dirty_flag(); - Task::none() + Vec::new() } - pub(super) fn handle_named_color_selected( - &mut self, - option: NamedColorOption, - ) -> Task { + pub(super) fn handle_named_color_selected(&mut self, option: NamedColorOption) -> Vec { self.status = StatusMessage::idle(); self.draft.drawing_color.selected_named = option; if option != NamedColorOption::Custom { self.draft.drawing_color.name = option.as_value().to_string(); } self.refresh_dirty_flag(); - Task::none() + Vec::new() } pub(super) fn handle_quick_color_mode_changed( &mut self, index: usize, mode: ColorMode, - ) -> Task { + ) -> Vec { self.status = StatusMessage::idle(); let Some(entry) = self.draft.drawing_quick_colors.get_mut(index) else { - return Task::none(); + return Vec::new(); }; let quick_color = &mut entry.color; if matches!(mode, ColorMode::Rgb) { @@ -127,17 +89,17 @@ impl ConfiguratorApp { } self.sync_color_picker_hex_for_id(ColorPickerId::QuickColor(index)); self.refresh_dirty_flag(); - Task::none() + Vec::new() } pub(super) fn handle_quick_named_color_selected( &mut self, index: usize, option: NamedColorOption, - ) -> Task { + ) -> Vec { self.status = StatusMessage::idle(); let Some(entry) = self.draft.drawing_quick_colors.get_mut(index) else { - return Task::none(); + return Vec::new(); }; let quick_color = &mut entry.color; quick_color.selected_named = option; @@ -145,42 +107,85 @@ impl ConfiguratorApp { quick_color.name = option.as_value().to_string(); } self.refresh_dirty_flag(); - Task::none() + Vec::new() } - pub(super) fn handle_quick_color_added(&mut self) -> Task { + pub(super) fn handle_quick_color_added(&mut self) -> Vec { self.status = StatusMessage::idle(); + let new_index = self.draft.drawing_quick_colors.entries.len(); self.draft.drawing_quick_colors.add_entry(); - self.sync_all_color_picker_hex(); + self.remap_quick_color_pickers(|index| (index < new_index).then_some(index)); self.refresh_dirty_flag(); - Task::none() + Vec::new() } - pub(super) fn handle_quick_color_removed(&mut self, index: usize) -> Task { + pub(super) fn handle_quick_color_removed(&mut self, index: usize) -> Vec { self.status = StatusMessage::idle(); if self.draft.drawing_quick_colors.remove_entry(index) { - self.color_picker_open = None; - self.sync_all_color_picker_hex(); + self.remap_quick_color_pickers(|picker_index| match picker_index.cmp(&index) { + std::cmp::Ordering::Less => Some(picker_index), + std::cmp::Ordering::Equal => None, + std::cmp::Ordering::Greater => picker_index.checked_sub(1), + }); self.refresh_dirty_flag(); } - Task::none() + Vec::new() } - pub(super) fn handle_quick_color_moved(&mut self, index: usize, delta: isize) -> Task { + pub(super) fn handle_quick_color_moved(&mut self, index: usize, delta: isize) -> Vec { self.status = StatusMessage::idle(); + let Some(target) = index.checked_add_signed(delta) else { + return Vec::new(); + }; if self.draft.drawing_quick_colors.move_entry(index, delta) { - self.color_picker_open = None; - self.sync_all_color_picker_hex(); + self.remap_quick_color_pickers(|picker_index| { + if picker_index == target { + Some(index) + } else if picker_index == index { + Some(target) + } else { + Some(picker_index) + } + }); self.refresh_dirty_flag(); } - Task::none() + Vec::new() + } + + /// Carries each surviving quick-color edit buffer to its row's new index. + /// + /// The buffer can be half-typed and therefore absent from the draft. Add, + /// move, and removal must preserve it with the row that survives, while a + /// deleted row's buffer must disappear so it cannot hold Save hostage. + fn remap_quick_color_pickers(&mut self, remap: impl Fn(usize) -> Option) { + let count = self.draft.drawing_quick_colors.entries.len(); + let mut preserved = Vec::new(); + self.color_picker_hex.retain(|id, text| match id { + ColorPickerId::QuickColor(index) => { + if let Some(next) = remap(*index).filter(|next| *next < count) { + preserved.push((next, text.clone())); + } + false + } + _ => true, + }); + for (index, text) in preserved { + self.color_picker_hex + .insert(ColorPickerId::QuickColor(index), text); + } + for index in 0..count { + let id = ColorPickerId::QuickColor(index); + if !self.color_picker_hex.contains_key(&id) { + self.sync_color_picker_hex_for_id(id); + } + } } - pub(super) fn handle_eraser_mode_changed(&mut self, option: EraserModeOption) -> Task { + pub(super) fn handle_eraser_mode_changed(&mut self, option: EraserModeOption) -> Vec { self.status = StatusMessage::idle(); self.draft.drawing_default_eraser_mode = option; self.refresh_dirty_flag(); - Task::none() + Vec::new() } pub(super) fn handle_drawing_mouse_drag_tool_changed( @@ -188,11 +193,11 @@ impl ConfiguratorApp { button: DragMouseButton, field: DragToolField, option: DragToolOption, - ) -> Task { + ) -> Vec { self.status = StatusMessage::idle(); self.draft.set_mouse_drag_tool(button, field, option); self.refresh_dirty_flag(); - Task::none() + Vec::new() } pub(super) fn handle_drawing_mouse_drag_color_changed( @@ -200,129 +205,129 @@ impl ConfiguratorApp { button: DragMouseButton, field: DragToolField, option: DragColorOption, - ) -> Task { + ) -> Vec { self.status = StatusMessage::idle(); self.draft.set_mouse_drag_color(button, field, option); self.refresh_dirty_flag(); - Task::none() + Vec::new() } pub(super) fn handle_status_position_changed( &mut self, option: StatusPositionOption, - ) -> Task { + ) -> Vec { self.status = StatusMessage::idle(); self.draft.ui_status_position = option; self.refresh_dirty_flag(); - Task::none() + Vec::new() } pub(super) fn handle_input_hud_mode_changed( &mut self, option: InputHudModeOption, - ) -> Task { + ) -> Vec { self.status = StatusMessage::idle(); self.draft.input_hud_mode = option; self.refresh_dirty_flag(); - Task::none() + Vec::new() } pub(super) fn handle_input_hud_position_changed( &mut self, option: InputHudPositionOption, - ) -> Task { + ) -> Vec { self.status = StatusMessage::idle(); self.draft.input_hud_position = option; self.refresh_dirty_flag(); - Task::none() + Vec::new() } - pub(super) fn handle_ui_theme_changed(&mut self, option: UiThemeOption) -> Task { + pub(super) fn handle_ui_theme_changed(&mut self, option: UiThemeOption) -> Vec { self.status = StatusMessage::idle(); self.draft.ui_theme = option; self.refresh_dirty_flag(); - Task::none() + Vec::new() } pub(super) fn handle_ui_reduced_motion_changed( &mut self, option: ReducedMotionOption, - ) -> Task { + ) -> Vec { self.status = StatusMessage::idle(); self.draft.ui_reduced_motion = option; self.refresh_dirty_flag(); - Task::none() + Vec::new() } pub(super) fn handle_toolbar_layout_mode_changed( &mut self, option: ToolbarLayoutModeOption, - ) -> Task { + ) -> Vec { self.status = StatusMessage::idle(); self.draft.apply_toolbar_layout_mode(option); self.refresh_dirty_flag(); - Task::none() + Vec::new() } pub(super) fn handle_toolbar_side_layout_changed( &mut self, option: ToolbarSideLayoutOption, - ) -> Task { + ) -> Vec { self.status = StatusMessage::idle(); self.draft.ui_toolbar_side_layout = option; self.refresh_dirty_flag(); - Task::none() + Vec::new() } pub(super) fn handle_toolbar_zoom_chip_display_changed( &mut self, option: ZoomChipDisplayOption, - ) -> Task { + ) -> Vec { self.status = StatusMessage::idle(); self.draft.ui_toolbar_zoom_chip_display = option; self.refresh_dirty_flag(); - Task::none() + Vec::new() } pub(super) fn handle_toolbar_rebind_modifier_changed( &mut self, option: ToolbarRebindModifierOption, - ) -> Task { + ) -> Vec { self.status = StatusMessage::idle(); self.draft.ui_toolbar_rebind_modifier = option; self.refresh_dirty_flag(); - Task::none() + Vec::new() } pub(super) fn handle_toolbar_override_mode_changed( &mut self, option: ToolbarLayoutModeOption, - ) -> Task { + ) -> Vec { self.override_mode = option; - Task::none() + Vec::new() } pub(super) fn handle_toolbar_override_changed( &mut self, field: ToolbarOverrideField, option: OverrideOption, - ) -> Task { + ) -> Vec { self.status = StatusMessage::idle(); self.draft .set_toolbar_override(self.override_mode, field, option); self.refresh_dirty_flag(); - Task::none() + Vec::new() } pub(super) fn handle_toolbar_item_visibility_changed( &mut self, id: ToolbarItemId, visible: bool, - ) -> Task { + ) -> Vec { self.status = StatusMessage::idle(); self.draft.set_toolbar_item_visible(id, visible); self.refresh_dirty_flag(); - Task::none() + Vec::new() } pub(super) fn handle_toolbar_item_move_requested( @@ -330,209 +335,188 @@ impl ConfiguratorApp { group: ToolbarItemOrderGroup, id: ToolbarItemId, delta: isize, - ) -> Task { + ) -> Vec { self.status = StatusMessage::idle(); self.draft.move_toolbar_item(group, id, delta); self.refresh_dirty_flag(); - Task::none() + Vec::new() } pub(super) fn handle_toolbar_item_order_reset( &mut self, group: ToolbarItemOrderGroup, - ) -> Task { + ) -> Vec { self.status = StatusMessage::idle(); self.draft.reset_toolbar_item_order(group); self.refresh_dirty_flag(); - Task::none() + Vec::new() } pub(super) fn handle_session_storage_mode_changed( &mut self, option: SessionStorageModeOption, - ) -> Task { + ) -> Vec { self.status = StatusMessage::idle(); self.draft.session_storage_mode = option; self.refresh_dirty_flag(); - Task::none() + Vec::new() } pub(super) fn handle_session_compression_changed( &mut self, option: SessionCompressionOption, - ) -> Task { + ) -> Vec { self.status = StatusMessage::idle(); self.draft.session_compression = option; self.refresh_dirty_flag(); - Task::none() + Vec::new() } pub(super) fn handle_presenter_tool_behavior_changed( &mut self, option: PresenterToolBehaviorOption, - ) -> Task { + ) -> Vec { self.status = StatusMessage::idle(); self.draft.presenter_tool_behavior = option; self.refresh_dirty_flag(); - Task::none() + Vec::new() } pub(super) fn handle_presenter_toolbar_mode_changed( &mut self, option: PresenterToolbarModeOption, - ) -> Task { + ) -> Vec { self.status = StatusMessage::idle(); self.draft.presenter_toolbar_mode = option; self.refresh_dirty_flag(); - Task::none() + Vec::new() } pub(super) fn handle_export_pdf_page_size_changed( &mut self, option: PdfPageSizeOption, - ) -> Task { + ) -> Vec { self.status = StatusMessage::idle(); self.draft.export_pdf_page_size = option; self.refresh_dirty_flag(); - Task::none() + Vec::new() } pub(super) fn handle_export_pdf_orientation_changed( &mut self, option: PdfOrientationOption, - ) -> Task { + ) -> Vec { self.status = StatusMessage::idle(); self.draft.export_pdf_orientation = option; self.refresh_dirty_flag(); - Task::none() + Vec::new() } pub(super) fn handle_export_pdf_fit_changed( &mut self, option: PdfFitModeOption, - ) -> Task { + ) -> Vec { self.status = StatusMessage::idle(); self.draft.export_pdf_fit = option; self.refresh_dirty_flag(); - Task::none() + Vec::new() } pub(super) fn handle_export_pdf_transparent_background_changed( &mut self, option: PdfTransparentBackgroundOption, - ) -> Task { + ) -> Vec { self.status = StatusMessage::idle(); self.draft.export_pdf_transparent_background = option; self.refresh_dirty_flag(); - Task::none() + Vec::new() } pub(super) fn handle_export_pdf_label_position_changed( &mut self, option: PdfLabelPositionOption, - ) -> Task { + ) -> Vec { self.status = StatusMessage::idle(); self.draft.export_pdf_label_position = option; self.refresh_dirty_flag(); - Task::none() + Vec::new() } pub(super) fn handle_export_pdf_label_content_changed( &mut self, option: PdfLabelContentModeOption, - ) -> Task { + ) -> Vec { self.status = StatusMessage::idle(); self.draft.export_pdf_label_content = option; self.refresh_dirty_flag(); - Task::none() + Vec::new() } - pub(super) fn handle_buffer_count_changed(&mut self, count: u32) -> Task { + pub(super) fn handle_buffer_count_changed(&mut self, count: u32) -> Vec { self.status = StatusMessage::idle(); self.draft .set_performance_choice(PerformanceFieldId::BufferCount, count); self.refresh_dirty_flag(); - Task::none() + Vec::new() } pub(super) fn handle_keybinding_changed( &mut self, field: KeybindingField, value: String, - ) -> Task { + ) -> Vec { self.status = StatusMessage::idle(); self.draft.keybindings.set(field, value); self.refresh_dirty_flag(); - Task::none() + Vec::new() } pub(super) fn handle_font_style_option_selected( &mut self, option: FontStyleOption, - ) -> Task { + ) -> Vec { self.status = StatusMessage::idle(); self.draft.drawing_font_style_option = option; if option != FontStyleOption::Custom { self.draft.drawing_font_style = option.canonical_value().to_string(); } self.refresh_dirty_flag(); - Task::none() + Vec::new() } pub(super) fn handle_font_weight_option_selected( &mut self, option: FontWeightOption, - ) -> Task { + ) -> Vec { self.status = StatusMessage::idle(); self.draft.drawing_font_weight_option = option; if option != FontWeightOption::Custom { self.draft.drawing_font_weight = option.canonical_value().to_string(); } self.refresh_dirty_flag(); - Task::none() + Vec::new() } #[cfg(feature = "tablet-input")] pub(super) fn handle_tablet_pressure_edit_mode_changed( &mut self, option: PressureThicknessEditModeOption, - ) -> Task { + ) -> Vec { self.status = StatusMessage::idle(); self.draft.tablet_pressure_thickness_edit_mode = option; self.refresh_dirty_flag(); - Task::none() + Vec::new() } #[cfg(feature = "tablet-input")] pub(super) fn handle_tablet_pressure_entry_mode_changed( &mut self, option: PressureThicknessEntryModeOption, - ) -> Task { + ) -> Vec { self.status = StatusMessage::idle(); self.draft.tablet_pressure_thickness_entry_mode = option; self.refresh_dirty_flag(); - Task::none() - } -} - -fn quad_field_picker_id(field: QuadField) -> Option { - match field { - QuadField::StatusBarBg => Some(ColorPickerId::StatusBarBg), - QuadField::StatusBarText => Some(ColorPickerId::StatusBarText), - QuadField::HighlightFill => Some(ColorPickerId::HighlightFill), - QuadField::HighlightOutline => Some(ColorPickerId::HighlightOutline), - QuadField::HelpBg => Some(ColorPickerId::HelpBg), - QuadField::HelpBorder => Some(ColorPickerId::HelpBorder), - QuadField::HelpText => Some(ColorPickerId::HelpText), - QuadField::ExportPdfLabelText => Some(ColorPickerId::ExportPdfLabelText), - QuadField::ExportPdfLabelBackground => Some(ColorPickerId::ExportPdfLabelBackground), - } -} - -fn triplet_field_picker_id(field: TripletField) -> ColorPickerId { - match field { - TripletField::DrawingColorRgb => ColorPickerId::DrawingColor, - TripletField::QuickColorRgb(index) => ColorPickerId::QuickColor(index), + Vec::new() } } @@ -543,7 +527,7 @@ mod tests { #[test] fn quick_color_mode_change_to_rgb_materializes_named_hex_preview() { - let (mut app, _cmd) = ConfiguratorApp::new_app(); + let (mut app, _effects) = ConfiguratorApp::new_app(); let entry = &mut app.draft.drawing_quick_colors.entries[1]; entry.color.mode = ColorMode::Named; entry.color.selected_named = NamedColorOption::Custom; @@ -567,9 +551,117 @@ mod tests { ); } + /// Deleting a quick color takes its picker's editing text with it. + /// + /// Without the prune the removed slot's text stays in the map with no row + /// left to show it, and text the save gate refuses keeps Save disabled + /// over a field the user can no longer reach. + #[test] + fn removing_the_last_quick_color_drops_the_hex_text_it_left_behind() { + let (mut app, _effects) = ConfiguratorApp::new_app(); + let _ = app.handle_quick_color_added(); + let last = app.draft.drawing_quick_colors.entries.len() - 1; + let _ = app + .handle_color_picker_hex_changed(ColorPickerId::QuickColor(last), "#12zz".to_string()); + assert_eq!(app.invalid_color_hex_count(), 1); + + let _ = app.handle_quick_color_removed(last); + + assert!( + !app.color_picker_hex + .contains_key(&ColorPickerId::QuickColor(last)), + "the removed slot's picker text must go with the row" + ); + assert_eq!(app.invalid_color_hex_count(), 0); + } + + #[test] + fn removing_a_different_quick_color_preserves_a_half_typed_hex() { + let (mut app, _effects) = ConfiguratorApp::new_app(); + let _ = app.handle_quick_color_added(); + let last = app.draft.drawing_quick_colors.entries.len() - 1; + let _ = + app.handle_color_picker_hex_changed(ColorPickerId::QuickColor(0), "#12zz".to_string()); + + let _ = app.handle_quick_color_removed(last); + + assert_eq!( + app.color_picker_hex + .get(&ColorPickerId::QuickColor(0)) + .map(String::as_str), + Some("#12zz") + ); + assert_eq!(app.invalid_color_hex_count(), 1); + } + + /// Reordering remaps every surviving slot, so the editing text follows the + /// row it was moved with rather than staying at its old position. + #[test] + fn moving_a_quick_color_resyncs_the_pickers_that_survive() { + let (mut app, _effects) = ConfiguratorApp::new_app(); + app.draft.drawing_quick_colors.entries[0].color.mode = ColorMode::Rgb; + app.draft.drawing_quick_colors.entries[0].color.rgb = + ["1".to_string(), "2".to_string(), "3".to_string()]; + app.draft.drawing_quick_colors.entries[1].color.mode = ColorMode::Rgb; + app.draft.drawing_quick_colors.entries[1].color.rgb = + ["4".to_string(), "5".to_string(), "6".to_string()]; + app.sync_all_color_picker_hex(); + let first = app + .color_picker_hex + .get(&ColorPickerId::QuickColor(0)) + .cloned(); + + let _ = app.handle_quick_color_moved(0, 1); + + assert_eq!( + app.color_picker_hex.get(&ColorPickerId::QuickColor(1)), + first.as_ref() + ); + } + + #[test] + fn adding_a_quick_color_preserves_a_half_typed_surviving_hex() { + let (mut app, _effects) = ConfiguratorApp::new_app(); + let _ = + app.handle_color_picker_hex_changed(ColorPickerId::QuickColor(0), "#12zz".to_string()); + + let _ = app.handle_quick_color_added(); + + assert_eq!( + app.color_picker_hex + .get(&ColorPickerId::QuickColor(0)) + .map(String::as_str), + Some("#12zz") + ); + assert_eq!(app.invalid_color_hex_count(), 1); + } + + #[test] + fn moving_a_quick_color_carries_its_half_typed_hex_with_it() { + let (mut app, _effects) = ConfiguratorApp::new_app(); + let _ = + app.handle_color_picker_hex_changed(ColorPickerId::QuickColor(0), "#12zz".to_string()); + + let _ = app.handle_quick_color_moved(0, 1); + + assert_eq!( + app.color_picker_hex + .get(&ColorPickerId::QuickColor(1)) + .map(String::as_str), + Some("#12zz") + ); + assert_ne!( + app.color_picker_hex + .get(&ColorPickerId::QuickColor(0)) + .map(String::as_str), + Some("#12zz") + ); + assert_eq!(app.invalid_color_hex_count(), 1); + } + #[test] fn quick_color_label_edit_does_not_change_slot_colors() { - let (mut app, _cmd) = ConfiguratorApp::new_app(); + let (mut app, _effects) = ConfiguratorApp::new_app(); let _ = app.handle_text_changed(TextField::QuickColorLabel(0), "RedNew".to_string()); diff --git a/configurator/src/app/update/mod.rs b/configurator/src/app/update/mod.rs index fe36267b..bae6c9d7 100644 --- a/configurator/src/app/update/mod.rs +++ b/configurator/src/app/update/mod.rs @@ -8,33 +8,49 @@ mod render_profiles; mod session_catalog; mod tabs; -use iced::Task; - -use crate::messages::Message; +use crate::messages::{CommandMessage, Message}; +use super::effects::Effect; use super::state::ConfiguratorApp; +pub(crate) use config::migration_offer_text; + impl ConfiguratorApp { - pub(crate) fn update_message(&mut self, message: Message) -> Task { + /// Dispatch for what a finished effect reports back. + /// + /// Separate from [`Self::update_message`] only because the payloads are: + /// a command result carries values that are moved into the model rather + /// than copied into a widget closure. + pub(crate) fn update_command(&mut self, message: CommandMessage) -> Vec { + match message { + CommandMessage::ConfigLoaded(result) => self.handle_config_loaded(result), + CommandMessage::ConfigSaved(result) => self.handle_config_saved(result), + CommandMessage::DaemonStatusLoaded(request_id, result) => { + self.handle_daemon_status_loaded(request_id, result) + } + CommandMessage::DaemonActionCompleted(result) => { + self.handle_daemon_action_completed(result) + } + CommandMessage::SessionCatalogLoaded(result) => { + self.handle_session_catalog_loaded(result) + } + CommandMessage::SessionCatalogActionCompleted(result) => { + self.handle_session_catalog_action_completed(result) + } + } + } + + pub(crate) fn update_message(&mut self, message: Message) -> Vec { match message { - Message::ConfigLoaded(result) => self.handle_config_loaded(result), Message::ReloadRequested => self.handle_reload_requested(), Message::ResetToDefaultsRequested => self.handle_reset_to_defaults_requested(), - Message::ResetToDefaultsConfirmed => self.handle_reset_to_defaults_confirmed(), - Message::ResetToDefaultsCanceled => self.handle_reset_to_defaults_canceled(), Message::SaveRequested => self.handle_save_requested(), - Message::ConfigSaved(result) => self.handle_config_saved(result), Message::MigrationApplyRequested => self.handle_migration_apply_requested(), Message::MigrationDismissed => self.handle_migration_dismissed(), - Message::DaemonStatusLoaded(request_id, result) => { - self.handle_daemon_status_loaded(request_id, result) - } Message::DaemonShortcutInputChanged(value) => { self.handle_daemon_shortcut_input_changed(value) } Message::DaemonActionRequested(action) => self.handle_daemon_action_requested(action), - Message::DaemonActionCompleted(result) => self.handle_daemon_action_completed(result), - Message::SessionCatalogLoaded(result) => self.handle_session_catalog_loaded(result), Message::SessionCatalogRefreshRequested => { self.handle_session_catalog_refresh_requested() } @@ -72,36 +88,18 @@ impl ConfiguratorApp { self.handle_session_catalog_clear_confirmed(id) } Message::SessionCatalogClearCanceled => self.handle_session_catalog_clear_canceled(), - Message::SessionCatalogActionCompleted(result) => { - self.handle_session_catalog_action_completed(result) - } Message::SearchChanged(value) => self.handle_search_changed(value), Message::SearchCleared => self.handle_search_cleared(), Message::SearchFocusRequested => self.handle_search_focus_requested(), - Message::SearchFocusObserved(is_focused) => { - self.handle_search_focus_observed(is_focused) - } - Message::KeyboardEvent(event, status) => self.handle_keyboard_event(event, status), - Message::PointerPressed => self.handle_pointer_pressed(), + Message::StartupInteractionObserved => self.handle_startup_interaction_observed(), Message::TabSelected(tab) => self.handle_tab_selected(tab), Message::UiTabSelected(tab) => self.handle_ui_tab_selected(tab), Message::KeybindingsTabSelected(tab) => self.handle_keybindings_tab_selected(tab), Message::ToggleChanged(field, value) => self.handle_toggle_changed(field, value), Message::TextChanged(field, value) => self.handle_text_changed(field, value), - Message::TripletChanged(field, index, value) => { - self.handle_triplet_changed(field, index, value) - } - Message::QuadChanged(field, index, value) => { - self.handle_quad_changed(field, index, value) - } - Message::ColorPickerToggled(id) => self.handle_color_picker_toggled(id), - Message::ColorPickerAdvancedToggled(id, value) => { - self.handle_color_picker_advanced_toggled(id, value) - } Message::ColorPickerHexChanged(id, value) => { self.handle_color_picker_hex_changed(id, value) } - Message::ColorPickerChanged(id, value) => self.handle_color_picker_changed(id, value), Message::ColorModeChanged(mode) => self.handle_color_mode_changed(mode), Message::NamedColorSelected(option) => self.handle_named_color_selected(option), Message::QuickColorAdded => self.handle_quick_color_added(), diff --git a/configurator/src/app/update/presets.rs b/configurator/src/app/update/presets.rs index aec2a654..688a6e41 100644 --- a/configurator/src/app/update/presets.rs +++ b/configurator/src/app/update/presets.rs @@ -1,43 +1,42 @@ -use iced::Task; use wayscriber::config::PRESET_SLOTS_MAX; -use crate::messages::Message; use crate::models::{ ColorMode, NamedColorOption, PresetEraserKindOption, PresetEraserModeOption, PresetTextField, PresetToggleField, }; +use super::super::effects::Effect; use super::super::state::{ConfiguratorApp, StatusMessage}; impl ConfiguratorApp { - pub(super) fn handle_preset_slot_count_changed(&mut self, count: usize) -> Task { + pub(super) fn handle_preset_slot_count_changed(&mut self, count: usize) -> Vec { self.status = StatusMessage::idle(); self.draft.presets.slot_count = count; self.refresh_dirty_flag(); - Task::none() + Vec::new() } pub(super) fn handle_preset_slot_enabled_changed( &mut self, slot_index: usize, enabled: bool, - ) -> Task { + ) -> Vec { self.status = StatusMessage::idle(); if let Some(slot) = self.draft.presets.slot_mut(slot_index) { slot.enabled = enabled; } self.refresh_dirty_flag(); - Task::none() + Vec::new() } - pub(super) fn handle_preset_collapse_toggled(&mut self, slot_index: usize) -> Task { + pub(super) fn handle_preset_collapse_toggled(&mut self, slot_index: usize) -> Vec { if let Some(collapsed) = self.preset_collapsed.get_mut(slot_index.saturating_sub(1)) { *collapsed = !*collapsed; } - Task::none() + Vec::new() } - pub(super) fn handle_preset_reset_slot(&mut self, slot_index: usize) -> Task { + pub(super) fn handle_preset_reset_slot(&mut self, slot_index: usize) -> Vec { self.status = StatusMessage::idle(); if let (Some(slot), Some(default_slot)) = ( self.draft.presets.slot_mut(slot_index), @@ -48,10 +47,10 @@ impl ConfiguratorApp { slot.enabled = enabled; } self.refresh_dirty_flag(); - Task::none() + Vec::new() } - pub(super) fn handle_preset_duplicate_slot(&mut self, slot_index: usize) -> Task { + pub(super) fn handle_preset_duplicate_slot(&mut self, slot_index: usize) -> Vec { self.status = StatusMessage::idle(); let target_index = slot_index + 1; if target_index <= PRESET_SLOTS_MAX @@ -68,27 +67,27 @@ impl ConfiguratorApp { } } self.refresh_dirty_flag(); - Task::none() + Vec::new() } pub(super) fn handle_preset_tool_changed( &mut self, slot_index: usize, tool: crate::models::ToolOption, - ) -> Task { + ) -> Vec { self.status = StatusMessage::idle(); if let Some(slot) = self.draft.presets.slot_mut(slot_index) { slot.set_tool(tool); } self.refresh_dirty_flag(); - Task::none() + Vec::new() } pub(super) fn handle_preset_color_mode_changed( &mut self, slot_index: usize, mode: ColorMode, - ) -> Task { + ) -> Vec { self.status = StatusMessage::idle(); if let Some(slot) = self.draft.presets.slot_mut(slot_index) { slot.color.mode = mode; @@ -102,14 +101,14 @@ impl ConfiguratorApp { } } self.refresh_dirty_flag(); - Task::none() + Vec::new() } pub(super) fn handle_preset_named_color_selected( &mut self, slot_index: usize, option: NamedColorOption, - ) -> Task { + ) -> Vec { self.status = StatusMessage::idle(); if let Some(slot) = self.draft.presets.slot_mut(slot_index) { slot.color.selected_named = option; @@ -118,7 +117,7 @@ impl ConfiguratorApp { } } self.refresh_dirty_flag(); - Task::none() + Vec::new() } pub(super) fn handle_preset_color_component_changed( @@ -126,7 +125,7 @@ impl ConfiguratorApp { slot_index: usize, component: usize, value: String, - ) -> Task { + ) -> Vec { self.status = StatusMessage::idle(); if let Some(slot) = self.draft.presets.slot_mut(slot_index) && let Some(entry) = slot.color.rgb.get_mut(component) @@ -134,7 +133,7 @@ impl ConfiguratorApp { *entry = value; } self.refresh_dirty_flag(); - Task::none() + Vec::new() } pub(super) fn handle_preset_text_changed( @@ -142,7 +141,7 @@ impl ConfiguratorApp { slot_index: usize, field: PresetTextField, value: String, - ) -> Task { + ) -> Vec { self.status = StatusMessage::idle(); if let Some(slot) = self.draft.presets.slot_mut(slot_index) { match field { @@ -161,7 +160,7 @@ impl ConfiguratorApp { } } self.refresh_dirty_flag(); - Task::none() + Vec::new() } pub(super) fn handle_preset_toggle_option_changed( @@ -169,7 +168,7 @@ impl ConfiguratorApp { slot_index: usize, field: PresetToggleField, value: crate::models::OverrideOption, - ) -> Task { + ) -> Vec { self.status = StatusMessage::idle(); if let Some(slot) = self.draft.presets.slot_mut(slot_index) { match field { @@ -182,32 +181,32 @@ impl ConfiguratorApp { } } self.refresh_dirty_flag(); - Task::none() + Vec::new() } pub(super) fn handle_preset_eraser_kind_changed( &mut self, slot_index: usize, value: PresetEraserKindOption, - ) -> Task { + ) -> Vec { self.status = StatusMessage::idle(); if let Some(slot) = self.draft.presets.slot_mut(slot_index) { slot.eraser_kind = value; } self.refresh_dirty_flag(); - Task::none() + Vec::new() } pub(super) fn handle_preset_eraser_mode_changed( &mut self, slot_index: usize, value: PresetEraserModeOption, - ) -> Task { + ) -> Vec { self.status = StatusMessage::idle(); if let Some(slot) = self.draft.presets.slot_mut(slot_index) { slot.eraser_mode = value; } self.refresh_dirty_flag(); - Task::none() + Vec::new() } } diff --git a/configurator/src/app/update/render_profiles.rs b/configurator/src/app/update/render_profiles.rs index da84f2c8..171c2379 100644 --- a/configurator/src/app/update/render_profiles.rs +++ b/configurator/src/app/update/render_profiles.rs @@ -1,24 +1,22 @@ -use iced::Task; - -use crate::messages::Message; use crate::models::{ ColorPickerId, RenderProfileExportOption, RenderProfileMappingDraft, RenderProfileMappingSide, RenderProfileTextField, }; +use super::super::effects::Effect; use super::super::state::{ConfiguratorApp, StatusMessage}; impl ConfiguratorApp { - pub(super) fn handle_render_profile_add(&mut self) -> Task { + pub(super) fn handle_render_profile_add(&mut self) -> Vec { self.status = StatusMessage::idle(); let profile = self.draft.render_profiles.new_profile(); self.draft.render_profiles.profiles.push(profile); self.sync_render_profile_color_picker_hex(); self.refresh_dirty_flag(); - Task::none() + Vec::new() } - pub(super) fn handle_render_profile_remove(&mut self, index: usize) -> Task { + pub(super) fn handle_render_profile_remove(&mut self, index: usize) -> Vec { self.status = StatusMessage::idle(); if index < self.draft.render_profiles.profiles.len() { self.draft.render_profiles.profiles.remove(index); @@ -26,10 +24,10 @@ impl ConfiguratorApp { self.draft.render_profiles.ensure_selections_exist(); self.refresh_dirty_flag(); } - Task::none() + Vec::new() } - pub(super) fn handle_render_profile_duplicate(&mut self, index: usize) -> Task { + pub(super) fn handle_render_profile_duplicate(&mut self, index: usize) -> Vec { self.status = StatusMessage::idle(); if let Some(profile) = self.draft.render_profiles.duplicate_profile(index) { self.draft @@ -39,7 +37,7 @@ impl ConfiguratorApp { self.clear_render_profile_color_pickers(); self.refresh_dirty_flag(); } - Task::none() + Vec::new() } pub(super) fn handle_render_profile_text_changed( @@ -47,7 +45,7 @@ impl ConfiguratorApp { index: usize, field: RenderProfileTextField, value: String, - ) -> Task { + ) -> Vec { self.status = StatusMessage::idle(); let old_id = self .draft @@ -73,20 +71,20 @@ impl ConfiguratorApp { } self.draft.render_profiles.ensure_selections_exist(); self.refresh_dirty_flag(); - Task::none() + Vec::new() } - pub(super) fn handle_render_profile_active_changed(&mut self, value: String) -> Task { + pub(super) fn handle_render_profile_active_changed(&mut self, value: String) -> Vec { self.status = StatusMessage::idle(); self.draft.render_profiles.active = value; self.refresh_dirty_flag(); - Task::none() + Vec::new() } pub(super) fn handle_render_profile_export_changed( &mut self, value: RenderProfileExportOption, - ) -> Task { + ) -> Vec { self.status = StatusMessage::idle(); self.draft.render_profiles.export = value; if value == RenderProfileExportOption::Profile @@ -96,40 +94,40 @@ impl ConfiguratorApp { self.draft.render_profiles.export_profile = first.clone(); } self.refresh_dirty_flag(); - Task::none() + Vec::new() } pub(super) fn handle_render_profile_export_profile_changed( &mut self, value: String, - ) -> Task { + ) -> Vec { self.status = StatusMessage::idle(); self.draft.render_profiles.export_profile = value; self.refresh_dirty_flag(); - Task::none() + Vec::new() } pub(super) fn handle_render_profile_apply_canvas_changed( &mut self, value: bool, - ) -> Task { + ) -> Vec { self.status = StatusMessage::idle(); self.draft.render_profiles.apply_to_canvas = value; self.refresh_dirty_flag(); - Task::none() + Vec::new() } - pub(super) fn handle_render_profile_apply_ui_changed(&mut self, value: bool) -> Task { + pub(super) fn handle_render_profile_apply_ui_changed(&mut self, value: bool) -> Vec { self.status = StatusMessage::idle(); self.draft.render_profiles.apply_to_ui = value; self.refresh_dirty_flag(); - Task::none() + Vec::new() } pub(super) fn handle_render_profile_mapping_add( &mut self, profile_index: usize, - ) -> Task { + ) -> Vec { self.status = StatusMessage::idle(); if let Some(profile) = self.draft.render_profiles.profiles.get_mut(profile_index) { profile.mappings.push(RenderProfileMappingDraft { @@ -139,14 +137,14 @@ impl ConfiguratorApp { self.sync_render_profile_color_picker_hex(); self.refresh_dirty_flag(); } - Task::none() + Vec::new() } pub(super) fn handle_render_profile_mapping_remove( &mut self, profile_index: usize, mapping_index: usize, - ) -> Task { + ) -> Vec { self.status = StatusMessage::idle(); if let Some(profile) = self.draft.render_profiles.profiles.get_mut(profile_index) && mapping_index < profile.mappings.len() @@ -155,7 +153,7 @@ impl ConfiguratorApp { self.clear_render_profile_color_pickers(); self.refresh_dirty_flag(); } - Task::none() + Vec::new() } pub(super) fn handle_render_profile_mapping_color_changed( @@ -164,7 +162,7 @@ impl ConfiguratorApp { mapping_index: usize, side: RenderProfileMappingSide, value: String, - ) -> Task { + ) -> Vec { self.status = StatusMessage::idle(); if let Some(mapping) = self .draft @@ -188,19 +186,10 @@ impl ConfiguratorApp { } self.refresh_dirty_flag(); } - Task::none() + Vec::new() } fn clear_render_profile_color_pickers(&mut self) { - if matches!( - self.color_picker_open, - Some( - ColorPickerId::RenderProfileMappingFrom(_, _) - | ColorPickerId::RenderProfileMappingTo(_, _) - ) - ) { - self.color_picker_open = None; - } self.color_picker_hex.retain(|id, _| { !matches!( id, @@ -208,13 +197,6 @@ impl ConfiguratorApp { | ColorPickerId::RenderProfileMappingTo(_, _) ) }); - self.color_picker_advanced.retain(|id| { - !matches!( - id, - ColorPickerId::RenderProfileMappingFrom(_, _) - | ColorPickerId::RenderProfileMappingTo(_, _) - ) - }); self.sync_render_profile_color_picker_hex(); } } diff --git a/configurator/src/app/update/session_catalog.rs b/configurator/src/app/update/session_catalog.rs index 4acb355b..5382757d 100644 --- a/configurator/src/app/update/session_catalog.rs +++ b/configurator/src/app/update/session_catalog.rs @@ -1,24 +1,15 @@ use std::path::PathBuf; -use iced::Task; - -use crate::app::session_catalog::{ - clear_session_catalog_entry, clear_session_catalog_tool_state_entry, - duplicate_session_catalog_entry, forget_session_catalog_entry, load_session_catalog, - move_session_catalog_entry, rename_session_catalog_entry, reveal_session_catalog_entry, - session_clear_cached_status_blocker, session_clear_tool_state_cached_status_blocker, - session_duplicate_cached_status_blocker, session_move_cached_status_blocker, -}; -use crate::messages::Message; -use crate::models::{SessionCatalogActionResult, SessionCatalogItem}; +use crate::models::{SessionCatalogActionResult, SessionCatalogItem, SessionCatalogOperation}; +use super::super::effects::Effect; use super::super::state::{ConfiguratorApp, StatusMessage}; impl ConfiguratorApp { pub(super) fn handle_session_catalog_loaded( &mut self, result: Result, String>, - ) -> Task { + ) -> Vec { match result { Ok(items) => { self.session_catalog.replace_items(items); @@ -37,220 +28,207 @@ impl ConfiguratorApp { StatusMessage::error(format!("Failed to load session catalog: {err}")); } } - Task::none() + Vec::new() } - pub(super) fn handle_session_catalog_refresh_requested(&mut self) -> Task { + pub(super) fn handle_session_catalog_refresh_requested(&mut self) -> Vec { if self.session_catalog.busy { - return Task::none(); + return Vec::new(); } self.session_catalog.is_loading = true; self.session_catalog.pending_clear_id = None; self.status = StatusMessage::info("Loading sessions..."); - Task::perform(load_session_catalog(), Message::SessionCatalogLoaded) + vec![Effect::LoadSessionCatalog] } - pub(super) fn handle_session_catalog_forget_requested(&mut self, id: String) -> Task { + pub(super) fn handle_session_catalog_forget_requested(&mut self, id: String) -> Vec { if self.session_catalog.busy { - return Task::none(); + return Vec::new(); } self.session_catalog.busy = true; self.session_catalog.pending_clear_id = None; self.status = StatusMessage::info("Forgetting session metadata..."); - Task::perform( - forget_session_catalog_entry(id), - Message::SessionCatalogActionCompleted, - ) + vec![Effect::ForgetSessionEntry { id }] } pub(super) fn handle_session_catalog_rename_input_changed( &mut self, id: String, value: String, - ) -> Task { + ) -> Vec { self.session_catalog.rename_inputs.insert(id, value); - Task::none() + Vec::new() } - pub(super) fn handle_session_catalog_rename_requested(&mut self, id: String) -> Task { + pub(super) fn handle_session_catalog_rename_requested(&mut self, id: String) -> Vec { if self.session_catalog.busy { - return Task::none(); + return Vec::new(); } let Some(item) = self.session_catalog.item(&id) else { self.status = StatusMessage::error("Session is no longer in the catalog."); - return Task::none(); + return Vec::new(); }; let display_name = self.session_catalog.rename_value(&id, &item.display_name); if display_name.trim().is_empty() { self.status = StatusMessage::error("Session display name cannot be empty."); - return Task::none(); + return Vec::new(); } self.session_catalog.busy = true; self.session_catalog.pending_clear_id = None; self.status = StatusMessage::info("Renaming session..."); - Task::perform( - rename_session_catalog_entry(id, display_name), - Message::SessionCatalogActionCompleted, - ) + vec![Effect::RenameSessionEntry { id, display_name }] } pub(super) fn handle_session_catalog_duplicate_input_changed( &mut self, id: String, value: String, - ) -> Task { + ) -> Vec { self.session_catalog.duplicate_inputs.insert(id, value); - Task::none() + Vec::new() } - pub(super) fn handle_session_catalog_duplicate_requested( - &mut self, - id: String, - ) -> Task { + pub(super) fn handle_session_catalog_duplicate_requested(&mut self, id: String) -> Vec { if self.session_catalog.busy { - return Task::none(); + return Vec::new(); } - if let Some(blocker) = session_duplicate_cached_status_blocker(self.daemon_status.as_ref()) + if let Some(blocker) = + SessionCatalogOperation::Duplicate.cached_status_blocker(self.daemon_status.as_ref()) { self.status = StatusMessage::warning(blocker); - return Task::none(); + return Vec::new(); } let Some(item) = self.session_catalog.item(&id) else { self.status = StatusMessage::error("Session is no longer in the catalog."); - return Task::none(); + return Vec::new(); }; let target = self.session_catalog.duplicate_value(&id, &item.path); if target.trim().is_empty() { self.status = StatusMessage::error("Duplicate Session target cannot be empty."); - return Task::none(); + return Vec::new(); } self.session_catalog.busy = true; self.session_catalog.pending_clear_id = None; self.status = StatusMessage::info("Duplicating session..."); - Task::perform( - duplicate_session_catalog_entry(id, PathBuf::from(target)), - Message::SessionCatalogActionCompleted, - ) + vec![Effect::DuplicateSessionEntry { + id, + target: PathBuf::from(target), + }] } pub(super) fn handle_session_catalog_move_input_changed( &mut self, id: String, value: String, - ) -> Task { + ) -> Vec { self.session_catalog.move_inputs.insert(id, value); - Task::none() + Vec::new() } - pub(super) fn handle_session_catalog_move_requested(&mut self, id: String) -> Task { + pub(super) fn handle_session_catalog_move_requested(&mut self, id: String) -> Vec { if self.session_catalog.busy { - return Task::none(); + return Vec::new(); } - if let Some(blocker) = session_move_cached_status_blocker(self.daemon_status.as_ref()) { + if let Some(blocker) = + SessionCatalogOperation::Move.cached_status_blocker(self.daemon_status.as_ref()) + { self.status = StatusMessage::warning(blocker); - return Task::none(); + return Vec::new(); } let Some(item) = self.session_catalog.item(&id) else { self.status = StatusMessage::error("Session is no longer in the catalog."); - return Task::none(); + return Vec::new(); }; let target = self.session_catalog.move_value(&id, &item.path); if target.trim().is_empty() { self.status = StatusMessage::error("Move Session target cannot be empty."); - return Task::none(); + return Vec::new(); } self.session_catalog.busy = true; self.session_catalog.pending_clear_id = None; self.status = StatusMessage::info("Moving session..."); - Task::perform( - move_session_catalog_entry(id, PathBuf::from(target)), - Message::SessionCatalogActionCompleted, - ) + vec![Effect::MoveSessionEntry { + id, + target: PathBuf::from(target), + }] } - pub(super) fn handle_session_catalog_reveal_requested(&mut self, id: String) -> Task { + pub(super) fn handle_session_catalog_reveal_requested(&mut self, id: String) -> Vec { if self.session_catalog.busy { - return Task::none(); + return Vec::new(); } self.session_catalog.busy = true; self.session_catalog.pending_clear_id = None; self.status = StatusMessage::info("Opening session folder..."); - Task::perform( - reveal_session_catalog_entry(id), - Message::SessionCatalogActionCompleted, - ) + vec![Effect::RevealSessionEntry { id }] } pub(super) fn handle_session_catalog_clear_tool_state_requested( &mut self, id: String, - ) -> Task { + ) -> Vec { if self.session_catalog.busy { - return Task::none(); + return Vec::new(); } - if let Some(blocker) = - session_clear_tool_state_cached_status_blocker(self.daemon_status.as_ref()) + if let Some(blocker) = SessionCatalogOperation::ClearToolState + .cached_status_blocker(self.daemon_status.as_ref()) { self.status = StatusMessage::warning(blocker); - return Task::none(); + return Vec::new(); } if self.session_catalog.item(&id).is_none() { self.status = StatusMessage::error("Session is no longer in the catalog."); - return Task::none(); + return Vec::new(); } self.session_catalog.busy = true; self.session_catalog.pending_clear_id = None; self.status = StatusMessage::info("Clearing saved tool state..."); - Task::perform( - clear_session_catalog_tool_state_entry(id), - Message::SessionCatalogActionCompleted, - ) + vec![Effect::ClearSessionToolState { id }] } - pub(super) fn handle_session_catalog_clear_requested(&mut self, id: String) -> Task { + pub(super) fn handle_session_catalog_clear_requested(&mut self, id: String) -> Vec { if self.session_catalog.busy { - return Task::none(); + return Vec::new(); } - if let Some(blocker) = session_clear_cached_status_blocker(self.daemon_status.as_ref()) { + if let Some(blocker) = + SessionCatalogOperation::Clear.cached_status_blocker(self.daemon_status.as_ref()) + { self.status = StatusMessage::warning(blocker); - return Task::none(); + return Vec::new(); } self.session_catalog.pending_clear_id = Some(id); self.status = StatusMessage::warning( "Clear saved data removes the selected session primary and non-lock sidecars. Press Confirm Clear to continue.", ); - Task::none() + Vec::new() } - pub(super) fn handle_session_catalog_clear_confirmed(&mut self, id: String) -> Task { + pub(super) fn handle_session_catalog_clear_confirmed(&mut self, id: String) -> Vec { if self.session_catalog.busy { - return Task::none(); + return Vec::new(); } if self.session_catalog.pending_clear_id.as_deref() != Some(id.as_str()) { - return Task::none(); + return Vec::new(); } self.session_catalog.busy = true; self.status = StatusMessage::info("Clearing saved session data..."); - Task::perform( - clear_session_catalog_entry(id), - Message::SessionCatalogActionCompleted, - ) + vec![Effect::ClearSessionEntry { id }] } - pub(super) fn handle_session_catalog_clear_canceled(&mut self) -> Task { + pub(super) fn handle_session_catalog_clear_canceled(&mut self) -> Vec { self.session_catalog.pending_clear_id = None; self.status = StatusMessage::idle(); - Task::none() + Vec::new() } pub(super) fn handle_session_catalog_action_completed( &mut self, result: Result, - ) -> Task { + ) -> Vec { self.session_catalog.busy = false; self.session_catalog.pending_clear_id = None; match result { @@ -266,7 +244,7 @@ impl ConfiguratorApp { self.status = StatusMessage::error(err); } } - Task::none() + Vec::new() } fn status_text(&self) -> Option<&str> { diff --git a/configurator/src/app/update/session_catalog/tests.rs b/configurator/src/app/update/session_catalog/tests.rs index 3be75a4e..23394ab1 100644 --- a/configurator/src/app/update/session_catalog/tests.rs +++ b/configurator/src/app/update/session_catalog/tests.rs @@ -88,7 +88,7 @@ fn status_contains(status: &StatusMessage, needle: &str) -> bool { #[test] fn rename_input_change_does_not_dirty_config() { - let (mut app, _task) = ConfiguratorApp::new_app(); + let (mut app, _effects) = ConfiguratorApp::new_app(); app.is_dirty = false; let _ = @@ -103,7 +103,7 @@ fn rename_input_change_does_not_dirty_config() { #[test] fn duplicate_input_change_does_not_dirty_config() { - let (mut app, _task) = ConfiguratorApp::new_app(); + let (mut app, _effects) = ConfiguratorApp::new_app(); app.is_dirty = false; let _ = app.handle_session_catalog_duplicate_input_changed( @@ -120,7 +120,7 @@ fn duplicate_input_change_does_not_dirty_config() { #[test] fn move_input_change_does_not_dirty_config() { - let (mut app, _task) = ConfiguratorApp::new_app(); + let (mut app, _effects) = ConfiguratorApp::new_app(); app.is_dirty = false; let _ = app.handle_session_catalog_move_input_changed( @@ -139,14 +139,15 @@ fn move_input_change_does_not_dirty_config() { fn duplicate_request_blocks_without_daemon_status() { let temp = crate::test_temp::tempdir().unwrap(); let _env = RuntimeEnvGuard::set_xdg_runtime_dir(temp.path()); - let (mut app, _task) = ConfiguratorApp::new_app(); + let (mut app, _effects) = ConfiguratorApp::new_app(); app.session_catalog = SessionCatalogState::loading(); app.session_catalog .replace_items(vec![catalog_item("s-1", "Lecture")]); app.daemon_status = None; - let _ = app.handle_session_catalog_duplicate_requested("s-1".to_string()); + let effects = app.handle_session_catalog_duplicate_requested("s-1".to_string()); + assert!(effects.is_empty()); assert!(!app.session_catalog.busy); assert!(status_contains(&app.status, "status finishes loading")); } @@ -155,14 +156,18 @@ fn duplicate_request_blocks_without_daemon_status() { fn duplicate_request_sets_busy_when_safe() { let temp = crate::test_temp::tempdir().unwrap(); let _env = RuntimeEnvGuard::set_xdg_runtime_dir(temp.path()); - let (mut app, _task) = ConfiguratorApp::new_app(); + let (mut app, _effects) = ConfiguratorApp::new_app(); app.session_catalog = SessionCatalogState::loading(); app.session_catalog .replace_items(vec![catalog_item("s-1", "Lecture")]); app.daemon_status = Some(inactive_daemon_status()); - let _ = app.handle_session_catalog_duplicate_requested("s-1".to_string()); + let effects = app.handle_session_catalog_duplicate_requested("s-1".to_string()); + assert!(matches!( + effects.as_slice(), + [Effect::DuplicateSessionEntry { id, .. }] if id == "s-1" + )); assert!(app.session_catalog.busy); assert!(status_contains(&app.status, "Duplicating session")); } @@ -171,14 +176,15 @@ fn duplicate_request_sets_busy_when_safe() { fn move_request_blocks_without_daemon_status() { let temp = crate::test_temp::tempdir().unwrap(); let _env = RuntimeEnvGuard::set_xdg_runtime_dir(temp.path()); - let (mut app, _task) = ConfiguratorApp::new_app(); + let (mut app, _effects) = ConfiguratorApp::new_app(); app.session_catalog = SessionCatalogState::loading(); app.session_catalog .replace_items(vec![catalog_item("s-1", "Lecture")]); app.daemon_status = None; - let _ = app.handle_session_catalog_move_requested("s-1".to_string()); + let effects = app.handle_session_catalog_move_requested("s-1".to_string()); + assert!(effects.is_empty()); assert!(!app.session_catalog.busy); assert!(status_contains(&app.status, "status finishes loading")); } @@ -187,14 +193,18 @@ fn move_request_blocks_without_daemon_status() { fn move_request_sets_busy_when_safe() { let temp = crate::test_temp::tempdir().unwrap(); let _env = RuntimeEnvGuard::set_xdg_runtime_dir(temp.path()); - let (mut app, _task) = ConfiguratorApp::new_app(); + let (mut app, _effects) = ConfiguratorApp::new_app(); app.session_catalog = SessionCatalogState::loading(); app.session_catalog .replace_items(vec![catalog_item("s-1", "Lecture")]); app.daemon_status = Some(inactive_daemon_status()); - let _ = app.handle_session_catalog_move_requested("s-1".to_string()); + let effects = app.handle_session_catalog_move_requested("s-1".to_string()); + assert!(matches!( + effects.as_slice(), + [Effect::MoveSessionEntry { id, .. }] if id == "s-1" + )); assert!(app.session_catalog.busy); assert!(status_contains(&app.status, "Moving session")); } @@ -203,7 +213,7 @@ fn move_request_sets_busy_when_safe() { fn clear_request_blocks_without_daemon_status() { let temp = crate::test_temp::tempdir().unwrap(); let _env = RuntimeEnvGuard::set_xdg_runtime_dir(temp.path()); - let (mut app, _task) = ConfiguratorApp::new_app(); + let (mut app, _effects) = ConfiguratorApp::new_app(); app.session_catalog = SessionCatalogState::loading(); app.session_catalog .replace_items(vec![catalog_item("s-1", "Lecture")]); @@ -219,7 +229,7 @@ fn clear_request_blocks_without_daemon_status() { fn clear_tool_state_request_blocks_without_daemon_status() { let temp = crate::test_temp::tempdir().unwrap(); let _env = RuntimeEnvGuard::set_xdg_runtime_dir(temp.path()); - let (mut app, _task) = ConfiguratorApp::new_app(); + let (mut app, _effects) = ConfiguratorApp::new_app(); app.session_catalog = SessionCatalogState::loading(); app.session_catalog .replace_items(vec![catalog_item("s-1", "Lecture")]); @@ -236,14 +246,18 @@ fn clear_tool_state_request_blocks_without_daemon_status() { fn clear_tool_state_request_sets_busy_when_safe() { let temp = crate::test_temp::tempdir().unwrap(); let _env = RuntimeEnvGuard::set_xdg_runtime_dir(temp.path()); - let (mut app, _task) = ConfiguratorApp::new_app(); + let (mut app, _effects) = ConfiguratorApp::new_app(); app.session_catalog = SessionCatalogState::loading(); app.session_catalog .replace_items(vec![catalog_item("s-1", "Lecture")]); app.daemon_status = Some(inactive_daemon_status()); - let _ = app.handle_session_catalog_clear_tool_state_requested("s-1".to_string()); + let effects = app.handle_session_catalog_clear_tool_state_requested("s-1".to_string()); + assert!(matches!( + effects.as_slice(), + [Effect::ClearSessionToolState { id }] if id == "s-1" + )); assert!(app.session_catalog.busy); assert!(app.session_catalog.pending_clear_id.is_none()); assert!(status_contains(&app.status, "Clearing saved tool state")); @@ -253,21 +267,23 @@ fn clear_tool_state_request_sets_busy_when_safe() { fn clear_request_sets_pending_confirmation_when_safe() { let temp = crate::test_temp::tempdir().unwrap(); let _env = RuntimeEnvGuard::set_xdg_runtime_dir(temp.path()); - let (mut app, _task) = ConfiguratorApp::new_app(); + let (mut app, _effects) = ConfiguratorApp::new_app(); app.session_catalog = SessionCatalogState::loading(); app.session_catalog .replace_items(vec![catalog_item("s-1", "Lecture")]); app.daemon_status = Some(inactive_daemon_status()); - let _ = app.handle_session_catalog_clear_requested("s-1".to_string()); + let effects = app.handle_session_catalog_clear_requested("s-1".to_string()); + // Confirmation first: nothing is cleared until the user says so again. + assert!(effects.is_empty()); assert_eq!(app.session_catalog.pending_clear_id.as_deref(), Some("s-1")); assert!(status_contains(&app.status, "Confirm Clear")); } #[test] fn action_completed_replaces_catalog_items() { - let (mut app, _task) = ConfiguratorApp::new_app(); + let (mut app, _effects) = ConfiguratorApp::new_app(); app.session_catalog.busy = true; let _ = app.handle_session_catalog_action_completed(Ok(SessionCatalogActionResult { @@ -284,7 +300,7 @@ fn action_completed_replaces_catalog_items() { #[test] fn warning_action_completed_sets_warning_status() { - let (mut app, _task) = ConfiguratorApp::new_app(); + let (mut app, _effects) = ConfiguratorApp::new_app(); app.session_catalog.busy = true; let _ = app.handle_session_catalog_action_completed(Ok(SessionCatalogActionResult { diff --git a/configurator/src/app/update/tabs.rs b/configurator/src/app/update/tabs.rs index c14245af..86f3249a 100644 --- a/configurator/src/app/update/tabs.rs +++ b/configurator/src/app/update/tabs.rs @@ -1,41 +1,36 @@ -use iced::Task; - -use crate::messages::Message; use crate::models::{DragMouseButton, KeybindingsTabId, TabId, UiTabId}; +use super::super::effects::Effect; use super::super::state::ConfiguratorApp; impl ConfiguratorApp { - pub(super) fn handle_tab_selected(&mut self, tab: TabId) -> Task { + pub(super) fn handle_tab_selected(&mut self, tab: TabId) -> Vec { self.active_tab = tab; self.align_active_tabs_for_search(); - Task::none() + Vec::new() } - pub(super) fn handle_ui_tab_selected(&mut self, tab: UiTabId) -> Task { + pub(super) fn handle_ui_tab_selected(&mut self, tab: UiTabId) -> Vec { self.active_ui_tab = tab; self.align_active_tabs_for_search(); - Task::none() + Vec::new() } - pub(super) fn handle_keybindings_tab_selected( - &mut self, - tab: KeybindingsTabId, - ) -> Task { + pub(super) fn handle_keybindings_tab_selected(&mut self, tab: KeybindingsTabId) -> Vec { self.active_keybindings_tab = tab; self.align_active_tabs_for_search(); - Task::none() + Vec::new() } pub(super) fn handle_drawing_drag_mapping_section_toggled( &mut self, button: DragMouseButton, - ) -> Task { + ) -> Vec { self.active_drawing_drag_button = if self.active_drawing_drag_button == Some(button) { None } else { Some(button) }; - Task::none() + Vec::new() } } diff --git a/configurator/src/app/view/arrow.rs b/configurator/src/app/view/arrow.rs deleted file mode 100644 index 63313362..00000000 --- a/configurator/src/app/view/arrow.rs +++ /dev/null @@ -1,48 +0,0 @@ -use iced::Element; -use iced::widget::{column, row, scrollable, text}; - -use crate::app::scroll::CONTENT_SCROLL_ID; -use crate::messages::Message; -use crate::models::{TextField, ToggleField}; - -use super::super::search::TabSearchSummary; -use super::super::state::ConfiguratorApp; -use super::widgets::{labeled_input_with_feedback, toggle_row, validate_f64_range}; - -impl ConfiguratorApp { - pub(super) fn arrow_tab(&self, _search: Option<&TabSearchSummary>) -> Element<'_, Message> { - scrollable( - column![ - text("Arrow Settings").size(20), - row![ - labeled_input_with_feedback( - "Arrow length (px)", - &self.draft.arrow_length, - &self.defaults.arrow_length, - TextField::ArrowLength, - Some("Range: 5-50 px"), - validate_f64_range(&self.draft.arrow_length, 5.0, 50.0), - ), - labeled_input_with_feedback( - "Arrow angle (deg)", - &self.draft.arrow_angle, - &self.defaults.arrow_angle, - TextField::ArrowAngle, - Some("Range: 15-60 deg"), - validate_f64_range(&self.draft.arrow_angle, 15.0, 60.0), - ) - ] - .spacing(12), - toggle_row( - "Place arrowhead at end of line", - self.draft.arrow_head_at_end, - self.defaults.arrow_head_at_end, - ToggleField::ArrowHeadAtEnd, - ) - ] - .spacing(12), - ) - .id(CONTENT_SCROLL_ID) - .into() - } -} diff --git a/configurator/src/app/view/boards/item.rs b/configurator/src/app/view/boards/item.rs deleted file mode 100644 index a50c747b..00000000 --- a/configurator/src/app/view/boards/item.rs +++ /dev/null @@ -1,197 +0,0 @@ -use crate::app::view::theme; -use iced::widget::{button, checkbox, column, container, pick_list, row, text, text_input}; -use iced::{Element, Length}; - -use crate::messages::Message; -use crate::models::{ - BoardBackgroundOption, BoardItemTextField, BoardItemToggleField, ColorPickerId, -}; - -use super::super::super::state::ConfiguratorApp; -use super::super::widgets::{ColorPickerUi, color_triplet_picker}; - -impl ConfiguratorApp { - pub(super) fn board_item_section_for_search( - &self, - index: usize, - force_expanded: bool, - ) -> Element<'_, Message> { - let Some(item) = self.draft.boards.items.get(index) else { - return text("Missing board").size(12).into(); - }; - let is_collapsed = - !force_expanded && self.boards_collapsed.get(index).copied().unwrap_or(false); - - let title = if item.name.trim().is_empty() { - format!("Board {}", index + 1) - } else { - item.name.trim().to_string() - }; - let id_label = if item.id.trim().is_empty() { - "id: ".to_string() - } else { - format!("id: {}", item.id.trim()) - }; - - let header = row![ - column![text(title).size(16), text(id_label).size(12)].spacing(2), - row![ - button(if is_collapsed { "Expand" } else { "Collapse" }) - .on_press(Message::BoardsCollapseToggled(index)), - button("Up").on_press(Message::BoardsMoveItemUp(index)), - button("Down").on_press(Message::BoardsMoveItemDown(index)), - button("Duplicate").on_press(Message::BoardsDuplicateItem(index)), - button("Remove") - .style(theme::Button::Secondary) - .on_press(Message::BoardsRemoveItem(index)), - ] - .spacing(6), - ] - .spacing(12) - .align_y(iced::Alignment::Center); - - if is_collapsed { - return container(column![header].spacing(8)) - .padding(12) - .style(theme::Container::Box) - .into(); - } - - let id_input = labeled_text_input("Board id", &item.id, index, BoardItemTextField::Id); - let name_input = - labeled_text_input("Display name", &item.name, index, BoardItemTextField::Name); - - let background_picker = pick_list( - BoardBackgroundOption::list(), - Some(item.background_kind), - move |value| Message::BoardsBackgroundKindChanged(index, value), - ) - .width(Length::Fill); - - let background_control = labeled_control_row("Background", background_picker.into()); - - let background_color_row = if item.background_kind == BoardBackgroundOption::Color { - let picker_id = ColorPickerId::BoardBackground(index); - let hex_value = self - .color_picker_hex - .get(&picker_id) - .map(String::as_str) - .unwrap_or(""); - color_triplet_picker( - "Background color (0-1)", - ColorPickerUi { - id: picker_id, - is_open: self.color_picker_open == Some(picker_id), - show_advanced: self.color_picker_advanced.contains(&picker_id), - hex_value, - }, - &item.background_color, - index, - Message::BoardsBackgroundColorChanged, - ) - } else { - text("Background color disabled for transparent boards") - .size(12) - .style(theme::Text::Color(iced::Color::from_rgb(0.6, 0.6, 0.6))) - .into() - }; - - let pen_override = row![ - checkbox(item.default_pen_color.enabled) - .label("Override default pen color") - .on_toggle(move |value| Message::BoardsDefaultPenEnabledChanged(index, value)), - ] - .spacing(8) - .align_y(iced::Alignment::Center); - - let pen_color_row = if item.default_pen_color.enabled { - let picker_id = ColorPickerId::BoardPen(index); - let hex_value = self - .color_picker_hex - .get(&picker_id) - .map(String::as_str) - .unwrap_or(""); - color_triplet_picker( - "Pen color (0-1)", - ColorPickerUi { - id: picker_id, - is_open: self.color_picker_open == Some(picker_id), - show_advanced: self.color_picker_advanced.contains(&picker_id), - hex_value, - }, - &item.default_pen_color.color, - index, - Message::BoardsDefaultPenColorChanged, - ) - } else { - text("Pen color override disabled") - .size(12) - .style(theme::Text::Color(iced::Color::from_rgb(0.6, 0.6, 0.6))) - .into() - }; - - let flags_row = row![ - checkbox(item.auto_adjust_pen) - .label("Auto-adjust pen") - .on_toggle(move |value| { - Message::BoardsItemToggleChanged( - index, - BoardItemToggleField::AutoAdjustPen, - value, - ) - }), - checkbox(item.persist) - .label("Persist") - .on_toggle(move |value| { - Message::BoardsItemToggleChanged(index, BoardItemToggleField::Persist, value) - }), - checkbox(item.pinned) - .label("Configured default pinned") - .on_toggle(move |value| { - Message::BoardsItemToggleChanged(index, BoardItemToggleField::Pinned, value) - }), - ] - .spacing(12) - .align_y(iced::Alignment::Center); - - let section = column![ - header, - id_input, - name_input, - background_control, - background_color_row, - pen_override, - pen_color_row, - flags_row, - ] - .spacing(10); - - container(section) - .padding(12) - .style(theme::Container::Box) - .into() - } -} - -fn labeled_text_input<'a>( - label: &'static str, - value: &'a str, - index: usize, - field: BoardItemTextField, -) -> Element<'a, Message> { - let input = text_input(label, value) - .on_input(move |val| Message::BoardsItemTextChanged(index, field, val)); - - column![row![text(label).size(14)].spacing(8), input] - .spacing(4) - .into() -} - -fn labeled_control_row<'a>( - label: &'static str, - control: Element<'a, Message>, -) -> Element<'a, Message> { - column![row![text(label).size(14)], control] - .spacing(4) - .into() -} diff --git a/configurator/src/app/view/boards/mod.rs b/configurator/src/app/view/boards/mod.rs deleted file mode 100644 index 507f0245..00000000 --- a/configurator/src/app/view/boards/mod.rs +++ /dev/null @@ -1,143 +0,0 @@ -mod item; - -use crate::app::view::theme; -use iced::widget::{button, column, pick_list, row, scrollable, text}; -use iced::{Element, Length}; - -use crate::app::scroll::CONTENT_SCROLL_ID; -use crate::messages::Message; -use crate::models::{TextField, ToggleField}; - -use super::super::search::{SearchArea, TabSearchSummary}; -use super::super::state::ConfiguratorApp; -use super::widgets::{ - labeled_control, labeled_input_with_feedback, toggle_row, validate_usize_min, -}; - -impl ConfiguratorApp { - pub(super) fn boards_tab(&self, search: Option<&TabSearchSummary>) -> Element<'_, Message> { - let defaults = &self.defaults.boards; - let boards = &self.draft.boards; - let show_all = search.is_none_or(TabSearchSummary::show_all); - let show_general = - search.is_none_or(|search| search.area_matches(SearchArea::BoardsGeneral)); - - let max_count = labeled_input_with_feedback( - "Max boards", - &boards.max_count, - &defaults.max_count, - TextField::BoardsMaxCount, - Some("Minimum: 1"), - validate_usize_min(&boards.max_count, 1), - ); - - let auto_create = toggle_row( - "Auto-create missing boards", - boards.auto_create, - defaults.auto_create, - ToggleField::BoardsAutoCreate, - ); - - let show_badge = toggle_row( - "Show board badge", - boards.show_board_badge, - defaults.show_board_badge, - ToggleField::BoardsShowBadge, - ); - - // Kept visible rather than hidden: the key is still in existing files, - // and a control that quietly disappears leaves the user to work out on - // their own why a setting they wrote stopped doing anything. Shown with - // the reason attached is the honest version, and the row still edits - // the value so the file's own text stays reachable. - let persist_customizations = toggle_row( - "Persist runtime customizations (deprecated)", - boards.persist_customizations, - defaults.persist_customizations, - ToggleField::BoardsPersistCustomizations, - ); - let persist_customizations_note = text( - "No effect: board renames, recolors, additions, and deletions belong to the running session, and nothing but Save writes config.toml. This setting is still parsed so older files load cleanly, and will be removed.", - ) - .size(12) - .style(theme::Text::Color(iced::Color::from_rgb(0.6, 0.6, 0.6))); - - let board_ids = boards.effective_ids(); - let selection = if board_ids.contains(&boards.default_board) { - Some(boards.default_board.clone()) - } else { - None - }; - - let default_board_control = if board_ids.is_empty() { - labeled_control( - "Default board", - text("Add a board to choose a default").size(12).into(), - defaults.default_board.clone(), - boards.default_board != defaults.default_board, - ) - } else { - let picker = - pick_list(board_ids, selection, Message::BoardsDefaultChanged).width(Length::Fill); - labeled_control( - "Default board", - picker.into(), - defaults.default_board.clone(), - boards.default_board != defaults.default_board, - ) - }; - - let add_button = button("Add board").on_press(Message::BoardsAddItem); - - // The list below is the seed, not the running session: the overlay's - // add/rename/recolor act on live boards and stop when it exits, so the - // heading has to say which of the two this screen edits. - let mut column = column![ - text("Boards").size(20), - text("Templates used to seed a new session. Boards you add or rename in the overlay belong to that session, not to this list.") - .size(12) - .style(theme::Text::Color(iced::Color::from_rgb(0.6, 0.6, 0.6))), - ] - .spacing(12); - - if show_general || show_all { - column = column - .push(max_count) - .push(auto_create) - .push(show_badge) - .push(persist_customizations) - .push(persist_customizations_note) - .push(default_board_control) - .push(row![add_button].spacing(8)); - } - - let loaded_legacy_boards = self - .base_document - .as_ref() - .is_some_and(|document| document.config().boards.is_none()); - if (show_general || show_all) && loaded_legacy_boards { - column = column.push( - text( - "Legacy [board] settings detected. Editing board settings will write [boards].", - ) - .size(12) - .style(theme::Text::Color(iced::Color::from_rgb(0.6, 0.6, 0.6))), - ); - } - - let indices: Vec = if show_all { - (0..boards.items.len()).collect() - } else { - search - .map(TabSearchSummary::board_indices) - .unwrap_or_default() - .to_vec() - }; - - for index in indices { - column = column.push(self.board_item_section_for_search(index, !show_all)); - } - - scrollable(column).id(CONTENT_SCROLL_ID).into() - } -} diff --git a/configurator/src/app/view/capture.rs b/configurator/src/app/view/capture.rs deleted file mode 100644 index 2463350f..00000000 --- a/configurator/src/app/view/capture.rs +++ /dev/null @@ -1,277 +0,0 @@ -use iced::widget::{column, pick_list, scrollable, text}; -use iced::{Element, Length}; - -use crate::app::scroll::CONTENT_SCROLL_ID; -use crate::messages::Message; -use crate::models::{ - ColorPickerId, PdfFitModeOption, PdfLabelContentModeOption, PdfLabelPositionOption, - PdfOrientationOption, PdfPageSizeOption, PdfTransparentBackgroundOption, QuadField, TextField, - ToggleField, -}; - -use super::super::search::{SearchArea, TabSearchSummary}; -use super::super::state::ConfiguratorApp; -use super::widgets::{ - ColorPickerUi, color_quad_picker, labeled_control, labeled_input, labeled_input_with_feedback, - toggle_row, validate_f64_range, -}; - -impl ConfiguratorApp { - pub(super) fn capture_tab(&self, search: Option<&TabSearchSummary>) -> Element<'_, Message> { - let show_files = search.is_none_or(|search| search.area_matches(SearchArea::CaptureFiles)); - let show_pdf = search.is_none_or(|search| search.area_matches(SearchArea::CapturePdf)); - let mut content = column![text("Capture Settings").size(20)].spacing(12); - - if show_files { - content = content - .push(toggle_row( - "Enable capture shortcuts", - self.draft.capture_enabled, - self.defaults.capture_enabled, - ToggleField::CaptureEnabled, - )) - .push(labeled_input( - "Save directory", - &self.draft.capture_save_directory, - &self.defaults.capture_save_directory, - TextField::CaptureSaveDirectory, - )) - .push(labeled_input( - "Filename template", - &self.draft.capture_filename_template, - &self.defaults.capture_filename_template, - TextField::CaptureFilename, - )) - .push(labeled_input( - "Format (png, jpg, ...)", - &self.draft.capture_format, - &self.defaults.capture_format, - TextField::CaptureFormat, - )) - .push(toggle_row( - "Copy to clipboard", - self.draft.capture_copy_to_clipboard, - self.defaults.capture_copy_to_clipboard, - ToggleField::CaptureCopyToClipboard, - )) - .push(toggle_row( - "Always exit overlay after capture", - self.draft.capture_exit_after, - self.defaults.capture_exit_after, - ToggleField::CaptureExitAfter, - )); - } - - if show_pdf { - content = content - .push(text("PDF Export").size(20)) - .push(labeled_input( - "PDF filename template (blank = capture template)", - &self.draft.export_pdf_filename_template, - &self.defaults.export_pdf_filename_template, - TextField::ExportPdfFilenameTemplate, - )) - .push(labeled_input( - "All boards PDF filename template", - &self.draft.export_pdf_all_boards_filename_template, - &self.defaults.export_pdf_all_boards_filename_template, - TextField::ExportPdfAllBoardsFilenameTemplate, - )) - .push(labeled_control( - "Page size", - pick_list( - PdfPageSizeOption::list(), - Some(self.draft.export_pdf_page_size), - Message::ExportPdfPageSizeChanged, - ) - .width(Length::Fill) - .into(), - self.defaults.export_pdf_page_size.label().to_string(), - self.draft.export_pdf_page_size != self.defaults.export_pdf_page_size, - )) - .push(labeled_control( - "Orientation", - pick_list( - PdfOrientationOption::list(), - Some(self.draft.export_pdf_orientation), - Message::ExportPdfOrientationChanged, - ) - .width(Length::Fill) - .into(), - self.defaults.export_pdf_orientation.label().to_string(), - self.draft.export_pdf_orientation != self.defaults.export_pdf_orientation, - )) - .push(labeled_control( - "Fit", - pick_list( - PdfFitModeOption::list(), - Some(self.draft.export_pdf_fit), - Message::ExportPdfFitChanged, - ) - .width(Length::Fill) - .into(), - self.defaults.export_pdf_fit.label().to_string(), - self.draft.export_pdf_fit != self.defaults.export_pdf_fit, - )) - .push(labeled_control( - "Transparent page background", - pick_list( - PdfTransparentBackgroundOption::list(), - Some(self.draft.export_pdf_transparent_background), - Message::ExportPdfTransparentBackgroundChanged, - ) - .width(Length::Fill) - .into(), - self.defaults - .export_pdf_transparent_background - .label() - .to_string(), - self.draft.export_pdf_transparent_background - != self.defaults.export_pdf_transparent_background, - )) - .push(labeled_input_with_feedback( - "Custom width (PDF points)", - &self.draft.export_pdf_custom_width, - &self.defaults.export_pdf_custom_width, - TextField::ExportPdfCustomWidth, - Some("Range: 1-14400"), - validate_f64_range(&self.draft.export_pdf_custom_width, 1.0, 14400.0), - )) - .push(labeled_input_with_feedback( - "Custom height (PDF points)", - &self.draft.export_pdf_custom_height, - &self.defaults.export_pdf_custom_height, - TextField::ExportPdfCustomHeight, - Some("Range: 1-14400"), - validate_f64_range(&self.draft.export_pdf_custom_height, 1.0, 14400.0), - )) - .push(labeled_input_with_feedback( - "Content source padding", - &self.draft.export_pdf_content_source_padding, - &self.defaults.export_pdf_content_source_padding, - TextField::ExportPdfContentSourcePadding, - Some("Range: 0-4096"), - validate_f64_range(&self.draft.export_pdf_content_source_padding, 0.0, 4096.0), - )) - .push(toggle_row( - "Show PDF page labels", - self.draft.export_pdf_labels_enabled, - self.defaults.export_pdf_labels_enabled, - ToggleField::ExportPdfLabelsEnabled, - )) - .push(labeled_control( - "Label position", - pick_list( - PdfLabelPositionOption::list(), - Some(self.draft.export_pdf_label_position), - Message::ExportPdfLabelPositionChanged, - ) - .width(Length::Fill) - .into(), - self.defaults.export_pdf_label_position.label().to_string(), - self.draft.export_pdf_label_position != self.defaults.export_pdf_label_position, - )) - .push(labeled_control( - "Label content", - pick_list( - PdfLabelContentModeOption::list(), - Some(self.draft.export_pdf_label_content), - Message::ExportPdfLabelContentChanged, - ) - .width(Length::Fill) - .into(), - self.defaults.export_pdf_label_content.label().to_string(), - self.draft.export_pdf_label_content != self.defaults.export_pdf_label_content, - )) - .push(labeled_input( - "Label template", - &self.draft.export_pdf_label_template, - &self.defaults.export_pdf_label_template, - TextField::ExportPdfLabelTemplate, - )) - .push(labeled_input( - "Label font family", - &self.draft.export_pdf_label_font_family, - &self.defaults.export_pdf_label_font_family, - TextField::ExportPdfLabelFontFamily, - )) - .push(labeled_input_with_feedback( - "Label font size", - &self.draft.export_pdf_label_font_size, - &self.defaults.export_pdf_label_font_size, - TextField::ExportPdfLabelFontSize, - Some("Range: 1-72"), - validate_f64_range(&self.draft.export_pdf_label_font_size, 1.0, 72.0), - )) - .push(labeled_input_with_feedback( - "Label margin", - &self.draft.export_pdf_label_margin, - &self.defaults.export_pdf_label_margin, - TextField::ExportPdfLabelMargin, - Some("Range: 0-240"), - validate_f64_range(&self.draft.export_pdf_label_margin, 0.0, 240.0), - )) - .push(labeled_input_with_feedback( - "Label horizontal padding", - &self.draft.export_pdf_label_padding_x, - &self.defaults.export_pdf_label_padding_x, - TextField::ExportPdfLabelPaddingX, - Some("Range: 0-120"), - validate_f64_range(&self.draft.export_pdf_label_padding_x, 0.0, 120.0), - )) - .push(labeled_input_with_feedback( - "Label vertical padding", - &self.draft.export_pdf_label_padding_y, - &self.defaults.export_pdf_label_padding_y, - TextField::ExportPdfLabelPaddingY, - Some("Range: 0-120"), - validate_f64_range(&self.draft.export_pdf_label_padding_y, 0.0, 120.0), - )) - .push(color_quad_picker( - "Label text RGBA (0-1)", - ColorPickerUi { - id: ColorPickerId::ExportPdfLabelText, - is_open: self.color_picker_open == Some(ColorPickerId::ExportPdfLabelText), - show_advanced: self - .color_picker_advanced - .contains(&ColorPickerId::ExportPdfLabelText), - hex_value: self - .color_picker_hex - .get(&ColorPickerId::ExportPdfLabelText) - .map(String::as_str) - .unwrap_or(""), - }, - &self.draft.export_pdf_label_text_color, - &self.defaults.export_pdf_label_text_color, - QuadField::ExportPdfLabelText, - )) - .push(toggle_row( - "Label solid background", - self.draft.export_pdf_label_background_enabled, - self.defaults.export_pdf_label_background_enabled, - ToggleField::ExportPdfLabelBackgroundEnabled, - )) - .push(color_quad_picker( - "Label background RGBA (0-1)", - ColorPickerUi { - id: ColorPickerId::ExportPdfLabelBackground, - is_open: self.color_picker_open - == Some(ColorPickerId::ExportPdfLabelBackground), - show_advanced: self - .color_picker_advanced - .contains(&ColorPickerId::ExportPdfLabelBackground), - hex_value: self - .color_picker_hex - .get(&ColorPickerId::ExportPdfLabelBackground) - .map(String::as_str) - .unwrap_or(""), - }, - &self.draft.export_pdf_label_background_color, - &self.defaults.export_pdf_label_background_color, - QuadField::ExportPdfLabelBackground, - )); - } - - scrollable(content).id(CONTENT_SCROLL_ID).into() - } -} diff --git a/configurator/src/app/view/daemon.rs b/configurator/src/app/view/daemon.rs deleted file mode 100644 index 792281c7..00000000 --- a/configurator/src/app/view/daemon.rs +++ /dev/null @@ -1,495 +0,0 @@ -use crate::app::view::theme; -use iced::Element; -use iced::widget::{button, column, row, rule, scrollable, text, text_input}; - -use crate::app::scroll::CONTENT_SCROLL_ID; -use crate::messages::Message; -use crate::models::{DaemonAction, LightShortcutApplyCapability, ShortcutApplyCapability}; - -use super::super::search::{SearchArea, TabSearchSummary}; -use super::super::state::ConfiguratorApp; - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -enum DaemonSection { - Install, - Shortcut, - LightControls, - Start, - TechnicalDetails, -} - -impl ConfiguratorApp { - pub(super) fn daemon_tab(&self, search: Option<&TabSearchSummary>) -> Element<'_, Message> { - let busy = self.daemon_busy; - let status_loading = self.daemon_status.is_none(); - let service_installed = self - .daemon_status - .as_ref() - .is_some_and(|status| status.service_installed); - let show_all = search.is_none_or(TabSearchSummary::show_all); - let show_status = - show_all || search.is_some_and(|search| search.area_matches(SearchArea::DaemonStatus)); - let show_service = - show_all || search.is_some_and(|search| search.area_matches(SearchArea::DaemonService)); - let show_shortcut = show_all - || search.is_some_and(|search| search.area_matches(SearchArea::DaemonShortcut)); - let show_light = show_all - || search.is_some_and(|search| search.area_matches(SearchArea::DaemonLightControls)); - - let mut content = column![].spacing(16); - - // ── Title and explanation ── - content = content.push(text("Background Mode").size(20)).push(text( - "Run wayscriber in the background and toggle it with a keyboard shortcut.", - )); - - if show_status { - content = content.push(self.daemon_overall_status(busy)); - } - - // ── Feedback banner ── - if let Some(feedback) = self.daemon_feedback.as_deref() { - let styled = if feedback.to_ascii_lowercase().contains("failed") - || feedback.to_ascii_lowercase().contains("error") - { - text(feedback).style(theme::Text::Color(iced::Color::from_rgb(1.0, 0.5, 0.5))) - } else { - text(feedback).style(theme::Text::Color(iced::Color::from_rgb(0.6, 0.9, 0.6))) - }; - content = content.push(styled); - } - - if busy { - content = content.push(text("Working...").size(12)); - } - - if status_loading { - content = content.push( - text("Checking your system and background service status...") - .size(14) - .style(theme::Text::Color(iced::Color::from_rgb(0.6, 0.6, 0.6))), - ); - if show_status || show_service { - content = content.push(self.daemon_technical_details(busy)); - } - return scrollable(content).id(CONTENT_SCROLL_ID).into(); - } - - for section in daemon_sections(show_status, show_service, show_shortcut, show_light) { - let body = match section { - DaemonSection::Install => self.daemon_step_install(busy), - DaemonSection::Shortcut => self.daemon_step_shortcut(busy, service_installed), - DaemonSection::LightControls => { - self.daemon_step_light_controls(busy, service_installed) - } - DaemonSection::Start => self.daemon_step_start(busy, service_installed), - DaemonSection::TechnicalDetails => self.daemon_technical_details(busy), - }; - content = content.push(rule::horizontal(1)).push(body); - } - - scrollable(content).id(CONTENT_SCROLL_ID).into() - } - - fn daemon_overall_status(&self, busy: bool) -> Element<'_, Message> { - let (label, color) = match self.daemon_status.as_ref() { - None => ("Status: Detecting...", iced::Color::from_rgb(0.6, 0.6, 0.6)), - Some(status) => { - if status.service_active { - ("Status: Running", iced::Color::from_rgb(0.5, 0.9, 0.5)) - } else if status.service_installed { - ( - "Status: Installed, not running", - iced::Color::from_rgb(0.95, 0.8, 0.3), - ) - } else { - ( - "Status: Not installed", - iced::Color::from_rgb(0.6, 0.6, 0.6), - ) - } - } - }; - - let mut refresh_button = button("Refresh").style(theme::Button::Secondary); - if !busy { - refresh_button = refresh_button - .on_press(Message::DaemonActionRequested(DaemonAction::RefreshStatus)); - } - - row![ - text(label).size(16).style(theme::Text::Color(color)), - refresh_button - ] - .spacing(12) - .into() - } - - fn daemon_step_install(&self, busy: bool) -> Element<'_, Message> { - let installed = self - .daemon_status - .as_ref() - .is_some_and(|s| s.service_installed); - - let status_indicator = if installed { - text("Installed \u{2713}") - .size(14) - .style(theme::Text::Color(iced::Color::from_rgb(0.5, 0.9, 0.5))) - } else { - text("Not installed") - .size(14) - .style(theme::Text::Color(iced::Color::from_rgb(0.6, 0.6, 0.6))) - }; - - let button_label = if installed { - "Update Service" - } else { - "Install Service" - }; - let mut install_button = button(button_label).style(theme::Button::Secondary); - if !busy { - install_button = install_button.on_press(Message::DaemonActionRequested( - DaemonAction::InstallOrUpdateService, - )); - } - - column![ - text("Step 1 — Install the service").size(16), - text("Install wayscriber as a background service.").size(14), - row![status_indicator, install_button].spacing(12), - ] - .spacing(8) - .into() - } - - fn daemon_step_shortcut(&self, busy: bool, service_installed: bool) -> Element<'_, Message> { - if !service_installed { - return column![ - text("Step 2 — Set your shortcut") - .size(16) - .style(theme::Text::Color(iced::Color::from_rgb(0.55, 0.55, 0.55))), - text("Install the background service first, then set your shortcut.") - .size(14) - .style(theme::Text::Color(iced::Color::from_rgb(0.55, 0.55, 0.55))), - ] - .spacing(8) - .into(); - } - - let apply_capability = self - .daemon_status - .as_ref() - .map(|s| s.shortcut_apply_capability); - let placeholder = match apply_capability { - Some(ShortcutApplyCapability::GnomeCustomShortcut) => "e.g. Super+G or g", - Some(ShortcutApplyCapability::PortalServiceDropIn) => { - "e.g. Ctrl+Shift+G or g" - } - _ => "e.g. Ctrl+Shift+G", - }; - - let mut shortcut_button = button("Apply Shortcut").style(theme::Button::Primary); - if !busy && apply_capability != Some(ShortcutApplyCapability::Manual) { - shortcut_button = shortcut_button - .on_press(Message::DaemonActionRequested(DaemonAction::ApplyShortcut)); - } - - let mut step = column![ - text("Step 2 — Set your shortcut").size(16), - text("Choose a keyboard shortcut to toggle drawing on/off.").size(14), - text( - "The shortcut takes effect after the background service is installed and running." - ) - .size(12) - .style(theme::Text::Color(iced::Color::from_rgb(0.6, 0.6, 0.6))), - ] - .spacing(8); - - if let Some(configured) = self - .daemon_status - .as_ref() - .and_then(|s| s.configured_shortcut.as_deref()) - { - step = step.push( - text(format!("Current shortcut: {configured}")) - .size(12) - .style(theme::Text::Color(iced::Color::from_rgb(0.6, 0.6, 0.6))), - ); - } - - if apply_capability == Some(ShortcutApplyCapability::Manual) { - step = step.push( - text("Automatic shortcut setup is unavailable here. Add a manual keybind for `wayscriber --daemon-toggle`.") - .size(12) - .style(theme::Text::Color(iced::Color::from_rgb(0.95, 0.8, 0.3))), - ); - } - - step = step.push( - text_input(placeholder, &self.daemon_shortcut_input) - .on_input(Message::DaemonShortcutInputChanged) - .padding(8), - ); - step = step.push(shortcut_button); - - step.into() - } - - fn daemon_step_light_controls( - &self, - busy: bool, - service_installed: bool, - ) -> Element<'_, Message> { - let capability = self - .daemon_status - .as_ref() - .map(|s| s.light_shortcut_apply_capability) - .unwrap_or(LightShortcutApplyCapability::Manual); - let configured = self - .daemon_status - .as_ref() - .is_some_and(|s| s.light_controls_configured); - - let status_indicator = if configured { - text("Configured \u{2713}") - .size(14) - .style(theme::Text::Color(iced::Color::from_rgb(0.5, 0.9, 0.5))) - } else { - text("Not configured") - .size(14) - .style(theme::Text::Color(iced::Color::from_rgb(0.6, 0.6, 0.6))) - }; - - let mut step = column![ - text("Light passthrough controls").size(16), - text("Install global controls for light passthrough and quick drawing.").size(14), - ] - .spacing(8); - - if let Some(path) = self - .daemon_status - .as_ref() - .and_then(|s| s.light_controls_config_path.as_deref()) - { - step = step.push( - text(format!("Hyprland include: {path}")) - .size(12) - .style(theme::Text::Color(iced::Color::from_rgb(0.6, 0.6, 0.6))), - ); - } - - match capability { - LightShortcutApplyCapability::HyprlandNative => { - let mut install_button = - button("Install Hyprland Light Controls").style(theme::Button::Primary); - if !busy && service_installed { - install_button = install_button.on_press(Message::DaemonActionRequested( - DaemonAction::ApplyLightControls, - )); - } - - if !service_installed { - step = step.push( - text("Install the background service first so these bindings have a daemon to control.") - .size(12) - .style(theme::Text::Color(iced::Color::from_rgb(0.95, 0.8, 0.3))), - ); - } - - step = step.push(row![status_indicator, install_button].spacing(12)); - } - LightShortcutApplyCapability::Manual => { - step = step.push( - text("Automatic light controls setup is unavailable here. Add compositor bindings for `wayscriber --light-toggle` and `wayscriber --light-draw-toggle`.") - .size(12) - .style(theme::Text::Color(iced::Color::from_rgb(0.95, 0.8, 0.3))), - ); - } - } - - step.into() - } - - fn daemon_step_start(&self, busy: bool, service_installed: bool) -> Element<'_, Message> { - if !service_installed { - return column![ - text("Step 3 — Start the service") - .size(16) - .style(theme::Text::Color(iced::Color::from_rgb(0.55, 0.55, 0.55))), - text("Install the background service first.") - .size(14) - .style(theme::Text::Color(iced::Color::from_rgb(0.55, 0.55, 0.55))), - ] - .spacing(8) - .into(); - } - - let enabled = self - .daemon_status - .as_ref() - .is_some_and(|status| status.service_enabled); - let running = self - .daemon_status - .as_ref() - .is_some_and(|s| s.service_active); - - let status_indicator = if running && enabled { - text("Running \u{2713}") - .size(14) - .style(theme::Text::Color(iced::Color::from_rgb(0.5, 0.9, 0.5))) - } else if running { - text("Running (not enabled)") - .size(14) - .style(theme::Text::Color(iced::Color::from_rgb(0.95, 0.8, 0.3))) - } else if enabled { - text("Enabled, not running") - .size(14) - .style(theme::Text::Color(iced::Color::from_rgb(0.95, 0.8, 0.3))) - } else { - text("Stopped and disabled") - .size(14) - .style(theme::Text::Color(iced::Color::from_rgb(0.6, 0.6, 0.6))) - }; - - let mut step = column![ - text("Step 3 — Start the service").size(16), - text("Enable and start the background service.").size(14), - status_indicator, - ] - .spacing(8); - - if running { - // Show Restart and Stop when running - let mut restart_button = button("Restart").style(theme::Button::Secondary); - if !busy { - restart_button = restart_button - .on_press(Message::DaemonActionRequested(DaemonAction::RestartService)); - } - let mut stop_button = button("Stop & Disable").style(theme::Button::Secondary); - if !busy { - stop_button = stop_button.on_press(Message::DaemonActionRequested( - DaemonAction::StopAndDisableService, - )); - } - step = step.push(row![restart_button, stop_button].spacing(12)); - } else { - // Show Start when not running - let mut start_button = button("Start").style(theme::Button::Primary); - if !busy { - start_button = start_button.on_press(Message::DaemonActionRequested( - DaemonAction::EnableAndStartService, - )); - } - step = step.push(start_button); - } - - step.into() - } - - fn daemon_technical_details(&self, busy: bool) -> Element<'_, Message> { - let mut details = column![text("Details").size(14)].spacing(4); - - if let Some(status) = self.daemon_status.as_ref() { - details = details.push( - text(format!("Desktop: {}", status.desktop.label())) - .size(12) - .style(theme::Text::Color(iced::Color::from_rgb(0.6, 0.6, 0.6))), - ); - - details = details.push( - text(status.shortcut_backend.friendly_label()) - .size(12) - .style(theme::Text::Color(iced::Color::from_rgb(0.6, 0.6, 0.6))), - ); - details = details.push( - text(status.shortcut_apply_capability.friendly_label()) - .size(12) - .style(theme::Text::Color(iced::Color::from_rgb(0.6, 0.6, 0.6))), - ); - details = details.push( - text(status.light_shortcut_apply_capability.friendly_label()) - .size(12) - .style(theme::Text::Color(iced::Color::from_rgb(0.6, 0.6, 0.6))), - ); - - if let Some(path) = status.service_unit_path.as_deref() { - details = details.push( - text(format!("Service file: {path}")) - .size(12) - .style(theme::Text::Color(iced::Color::from_rgb(0.6, 0.6, 0.6))), - ); - } - if let Some(path) = status.light_controls_config_path.as_deref() { - let label = if status.light_controls_configured { - format!("Light controls: configured at {path}") - } else { - format!("Light controls include: {path}") - }; - details = details.push( - text(label) - .size(12) - .style(theme::Text::Color(iced::Color::from_rgb(0.6, 0.6, 0.6))), - ); - } - - // Only show tool availability when something is missing - if !status.systemctl_available || !status.gsettings_available { - let mut missing = Vec::new(); - if !status.systemctl_available { - missing.push("systemctl"); - } - if !status.gsettings_available { - missing.push("gsettings"); - } - details = details.push( - text(format!("Missing tools: {}", missing.join(", "))) - .size(12) - .style(theme::Text::Color(iced::Color::from_rgb(0.95, 0.8, 0.3))), - ); - } - } else { - details = details.push( - text("Detecting environment...") - .size(12) - .style(theme::Text::Color(iced::Color::from_rgb(0.6, 0.6, 0.6))), - ); - } - - let mut refresh_button = button("Refresh").style(theme::Button::Secondary); - if !busy { - refresh_button = refresh_button - .on_press(Message::DaemonActionRequested(DaemonAction::RefreshStatus)); - } - details = details.push(refresh_button); - - details.into() - } -} - -fn daemon_sections( - show_status: bool, - show_service: bool, - show_shortcut: bool, - show_light: bool, -) -> Vec { - let mut sections = Vec::new(); - if show_service { - sections.push(DaemonSection::Install); - } - if show_shortcut { - sections.push(DaemonSection::Shortcut); - } - if show_light { - sections.push(DaemonSection::LightControls); - } - if show_service { - sections.push(DaemonSection::Start); - } - if show_status || show_service { - sections.push(DaemonSection::TechnicalDetails); - } - sections -} - -#[cfg(test)] -mod tests; diff --git a/configurator/src/app/view/daemon/tests.rs b/configurator/src/app/view/daemon/tests.rs deleted file mode 100644 index acc64629..00000000 --- a/configurator/src/app/view/daemon/tests.rs +++ /dev/null @@ -1,15 +0,0 @@ -use super::*; - -#[test] -fn daemon_sections_keep_default_setup_order() { - assert_eq!( - daemon_sections(true, true, true, true), - vec![ - DaemonSection::Install, - DaemonSection::Shortcut, - DaemonSection::LightControls, - DaemonSection::Start, - DaemonSection::TechnicalDetails, - ], - ); -} diff --git a/configurator/src/app/view/drawing/color.rs b/configurator/src/app/view/drawing/color.rs deleted file mode 100644 index 4b8cd8b3..00000000 --- a/configurator/src/app/view/drawing/color.rs +++ /dev/null @@ -1,324 +0,0 @@ -use crate::app::view::theme; -use iced::widget::{Column, Row, button, column, container, pick_list, row, text, text_input}; -use iced::{Alignment, Element, Length}; -use wayscriber::config::QUICK_COLOR_RENDER_LIMIT; - -use crate::messages::Message; -use crate::models::{ColorMode, ColorPickerId, NamedColorOption, TextField, TripletField}; - -use super::super::super::state::ConfiguratorApp; -use super::super::widgets::{ - COLOR_PICKER_WIDTH, ColorPickerUi, DEFAULT_LABEL_GAP, color_preview_labeled, - color_rgb255_picker, default_value_text, -}; - -pub(super) fn drawing_color_block(app: &ConfiguratorApp) -> Element<'_, Message> { - let color_mode_picker = Row::new() - .spacing(12) - .push( - button("Named Color") - .style(if app.draft.drawing_color.mode == ColorMode::Named { - theme::Button::Primary - } else { - theme::Button::Secondary - }) - .on_press(Message::ColorModeChanged(ColorMode::Named)), - ) - .push( - button("RGB Color") - .style(if app.draft.drawing_color.mode == ColorMode::Rgb { - theme::Button::Primary - } else { - theme::Button::Secondary - }) - .on_press(Message::ColorModeChanged(ColorMode::Rgb)), - ); - - let color_section: Element<'_, Message> = match app.draft.drawing_color.mode { - ColorMode::Named => named_color_section(app), - ColorMode::Rgb => rgb_color_section(app), - }; - - column![ - row![ - text("Pen color").size(14), - default_value_text( - app.defaults.drawing_color.summary(), - app.draft.drawing_color != app.defaults.drawing_color, - ), - ] - .spacing(DEFAULT_LABEL_GAP) - .align_y(Alignment::Center), - color_mode_picker, - color_section - ] - .spacing(8) - .into() -} - -pub(super) fn quick_colors_block(app: &ConfiguratorApp) -> Element<'_, Message> { - let mut column = Column::new().spacing(10).push( - row![ - text("Quick colors").size(16), - button("Add color") - .style(theme::Button::Secondary) - .on_press(Message::QuickColorAdded), - ] - .spacing(12) - .align_y(Alignment::Center), - ); - if let Some(warning) = - quick_color_render_limit_warning(app.draft.drawing_quick_colors.entries.len()) - { - column = column.push( - container(text(warning).size(12)) - .padding(8) - .width(Length::Fill) - .style(theme::Container::Warning), - ); - } - - for index in 0..app.draft.drawing_quick_colors.entries.len() { - column = column.push(quick_color_entry_block(app, index)); - } - - column.into() -} - -fn quick_color_render_limit_warning(count: usize) -> Option { - (count > QUICK_COLOR_RENDER_LIMIT).then(|| { - format!( - "Only the first {QUICK_COLOR_RENDER_LIMIT} quick colors are shown in toolbar and radial menus." - ) - }) -} - -fn named_color_section(app: &ConfiguratorApp) -> Element<'_, Message> { - let picker = pick_list( - NamedColorOption::list(), - Some(app.draft.drawing_color.selected_named), - Message::NamedColorSelected, - ) - .width(Length::Fixed(COLOR_PICKER_WIDTH)); - - let picker_row = row![ - picker, - color_preview_labeled(app.draft.drawing_color.preview_color()), - ] - .spacing(8) - .align_y(Alignment::Center); - - let mut column = Column::new().spacing(8).push(picker_row); - - if app.draft.drawing_color.selected_named_is_custom() { - column = column.push( - text_input("Custom color name", &app.draft.drawing_color.name) - .on_input(|value| Message::TextChanged(TextField::DrawingColorName, value)) - .width(Length::Fill), - ); - - if app.draft.drawing_color.preview_color().is_none() - && !app.draft.drawing_color.name.trim().is_empty() - { - column = column.push( - text("Unknown color name") - .size(12) - .style(theme::Text::Color(iced::Color::from_rgb(0.95, 0.6, 0.6))), - ); - } - } - - column.into() -} - -fn quick_color_entry_block(app: &ConfiguratorApp, index: usize) -> Element<'_, Message> { - let Some(current) = app.draft.drawing_quick_colors.get(index) else { - return column![].into(); - }; - let default = app.defaults.drawing_quick_colors.get(index); - let changed = default != Some(current); - let default_summary = default - .map(|entry| format!("{} / {}", entry.label.trim(), entry.color.summary())) - .unwrap_or_else(|| "not set".to_string()); - let quick_color_count = app.draft.drawing_quick_colors.entries.len(); - - let label_row = row![ - text(format!("Color {}", index + 1)).size(14), - default_value_text(default_summary, changed), - ] - .spacing(DEFAULT_LABEL_GAP) - .align_y(Alignment::Center); - - let mut remove_button = button("Remove").style(theme::Button::Secondary); - if quick_color_count > 8 { - remove_button = remove_button.on_press(Message::QuickColorRemoved(index)); - } - - let controls = row![ - text_input("Label", ¤t.label) - .on_input(move |value| Message::TextChanged(TextField::QuickColorLabel(index), value)) - .width(Length::Fill), - button("Up").on_press(Message::QuickColorMoved(index, -1)), - button("Down").on_press(Message::QuickColorMoved(index, 1)), - remove_button, - ] - .spacing(8) - .align_y(Alignment::Center); - - let color_mode_picker = Row::new() - .spacing(8) - .push( - button("Named / Hex") - .style(if current.color.mode == ColorMode::Named { - theme::Button::Primary - } else { - theme::Button::Secondary - }) - .on_press(Message::QuickColorModeChanged(index, ColorMode::Named)), - ) - .push( - button("RGB") - .style(if current.color.mode == ColorMode::Rgb { - theme::Button::Primary - } else { - theme::Button::Secondary - }) - .on_press(Message::QuickColorModeChanged(index, ColorMode::Rgb)), - ); - - let editor = match current.color.mode { - ColorMode::Named => quick_named_color_section(app, index), - ColorMode::Rgb => quick_rgb_color_section(app, index), - }; - - column![label_row, controls, color_mode_picker, editor,] - .spacing(6) - .into() -} - -fn quick_named_color_section(app: &ConfiguratorApp, index: usize) -> Element<'_, Message> { - let Some(entry) = app.draft.drawing_quick_colors.get(index) else { - return column![].into(); - }; - let color = &entry.color; - let picker = pick_list( - NamedColorOption::list(), - Some(color.selected_named), - move |value| Message::QuickNamedColorSelected(index, value), - ) - .width(Length::Fixed(COLOR_PICKER_WIDTH)); - - let picker_row = row![picker, color_preview_labeled(color.preview_color())] - .spacing(8) - .align_y(Alignment::Center); - - let mut column = Column::new().spacing(8).push(picker_row); - - if color.selected_named_is_custom() { - column = column.push( - text_input("Known color name or #RRGGBB[AA]", &color.name) - .on_input(move |value| { - Message::TextChanged(TextField::QuickColorName(index), value) - }) - .width(Length::Fill), - ); - - if color.preview_color().is_none() && !color.name.trim().is_empty() { - column = column.push( - text("Use a known color name, #RRGGBB, or #RRGGBBAA for alpha") - .size(12) - .style(theme::Text::Color(iced::Color::from_rgb(0.95, 0.6, 0.6))), - ); - } - } - - column.into() -} - -fn quick_rgb_color_section(app: &ConfiguratorApp, index: usize) -> Element<'_, Message> { - let Some(entry) = app.draft.drawing_quick_colors.get(index) else { - return column![].into(); - }; - let picker_id = ColorPickerId::QuickColor(index); - let color = &entry.color; - let rgb_picker = color_rgb255_picker( - ColorPickerUi { - id: picker_id, - is_open: app.color_picker_open == Some(picker_id), - show_advanced: false, - hex_value: app - .color_picker_hex - .get(&picker_id) - .map(String::as_str) - .unwrap_or(""), - }, - &color.rgb, - color.preview_color(), - TripletField::QuickColorRgb(index), - ); - - let mut column = Column::new().spacing(8).push(rgb_picker); - - if color.preview_color().is_none() && color.rgb.iter().any(|value| !value.trim().is_empty()) { - column = column.push( - text("RGB values must be between 0 and 255") - .size(12) - .style(theme::Text::Color(iced::Color::from_rgb(0.95, 0.6, 0.6))), - ); - } - - column.into() -} - -fn rgb_color_section(app: &ConfiguratorApp) -> Element<'_, Message> { - let picker_id = ColorPickerId::DrawingColor; - let rgb_picker = color_rgb255_picker( - ColorPickerUi { - id: picker_id, - is_open: app.color_picker_open == Some(picker_id), - show_advanced: false, - hex_value: app - .color_picker_hex - .get(&picker_id) - .map(String::as_str) - .unwrap_or(""), - }, - &app.draft.drawing_color.rgb, - app.draft.drawing_color.preview_color(), - TripletField::DrawingColorRgb, - ); - - let mut column = Column::new().spacing(8).push(rgb_picker); - - if app.draft.drawing_color.preview_color().is_none() - && app - .draft - .drawing_color - .rgb - .iter() - .any(|value| !value.trim().is_empty()) - { - column = column.push( - text("RGB values must be between 0 and 255") - .size(12) - .style(theme::Text::Color(iced::Color::from_rgb(0.95, 0.6, 0.6))), - ); - } - - column.into() -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn quick_color_render_limit_warning_starts_after_render_cap() { - assert!(quick_color_render_limit_warning(QUICK_COLOR_RENDER_LIMIT).is_none()); - - let warning = quick_color_render_limit_warning(QUICK_COLOR_RENDER_LIMIT + 1) - .expect("expected warning once quick colors exceed render cap"); - - assert!(warning.contains(&QUICK_COLOR_RENDER_LIMIT.to_string())); - } -} diff --git a/configurator/src/app/view/drawing/font.rs b/configurator/src/app/view/drawing/font.rs deleted file mode 100644 index d162e20d..00000000 --- a/configurator/src/app/view/drawing/font.rs +++ /dev/null @@ -1,76 +0,0 @@ -use iced::widget::{column, pick_list, row, text}; -use iced::{Alignment, Element, Length}; - -use crate::messages::Message; -use crate::models::{FontStyleOption, FontWeightOption, TextField}; - -use super::super::super::state::ConfiguratorApp; -use super::super::widgets::{DEFAULT_LABEL_GAP, default_value_text, labeled_input}; - -pub(super) fn font_controls(app: &ConfiguratorApp) -> Element<'_, Message> { - let weight_column = column![ - row![ - text("Font weight").size(14), - default_value_text( - app.defaults.drawing_font_weight.clone(), - app.draft.drawing_font_weight != app.defaults.drawing_font_weight, - ) - ] - .spacing(DEFAULT_LABEL_GAP) - .align_y(Alignment::Center), - pick_list( - FontWeightOption::list(), - Some(app.draft.drawing_font_weight_option), - Message::FontWeightOptionSelected, - ) - .width(Length::Fill), - labeled_input( - "Custom or numeric weight", - &app.draft.drawing_font_weight, - &app.defaults.drawing_font_weight, - TextField::DrawingFontWeight, - ) - ] - .spacing(6); - - let mut style_column = column![ - row![ - text("Font style").size(14), - default_value_text( - app.defaults.drawing_font_style.clone(), - app.draft.drawing_font_style != app.defaults.drawing_font_style, - ) - ] - .spacing(DEFAULT_LABEL_GAP) - .align_y(Alignment::Center), - pick_list( - FontStyleOption::list(), - Some(app.draft.drawing_font_style_option), - Message::FontStyleOptionSelected, - ) - .width(Length::Fill), - ] - .spacing(6); - - if app.draft.drawing_font_style_option == FontStyleOption::Custom { - style_column = style_column.push(labeled_input( - "Custom style", - &app.draft.drawing_font_style, - &app.defaults.drawing_font_style, - TextField::DrawingFontStyle, - )); - } - - row![ - labeled_input( - "Font family", - &app.draft.drawing_font_family, - &app.defaults.drawing_font_family, - TextField::DrawingFontFamily, - ), - weight_column, - style_column, - ] - .spacing(12) - .into() -} diff --git a/configurator/src/app/view/drawing/mod.rs b/configurator/src/app/view/drawing/mod.rs deleted file mode 100644 index f9e1e500..00000000 --- a/configurator/src/app/view/drawing/mod.rs +++ /dev/null @@ -1,344 +0,0 @@ -mod color; -mod font; - -use iced::widget::{button, column, pick_list, row, scrollable, text}; -use iced::{Element, Length}; - -use crate::app::scroll::CONTENT_SCROLL_ID; -use crate::messages::Message; -use crate::models::{ - DragColorOption, DragMouseButton, DragToolField, DragToolOption, EraserModeOption, TextField, - ToggleField, -}; -use wayscriber::config::DragButtonConfig; - -use self::color::{drawing_color_block, quick_colors_block}; -use self::font::font_controls; -use super::super::search::{SearchArea, TabSearchSummary}; -use super::super::state::ConfiguratorApp; -use super::theme; -use super::widgets::{ - labeled_control, labeled_input_with_feedback, toggle_row, validate_f64_range, - validate_usize_min, validate_usize_range, -}; - -impl ConfiguratorApp { - pub(super) fn drawing_tab(&self, search: Option<&TabSearchSummary>) -> Element<'_, Message> { - let show_color = search.is_none_or(|search| search.area_matches(SearchArea::DrawingColor)); - let show_defaults = - search.is_none_or(|search| search.area_matches(SearchArea::DrawingDefaults)); - let show_drag = - search.is_none_or(|search| search.area_matches(SearchArea::DrawingDragTools)); - let show_font = search.is_none_or(|search| search.area_matches(SearchArea::DrawingFont)); - let eraser_mode_pick = pick_list( - EraserModeOption::list(), - Some(self.draft.drawing_default_eraser_mode), - Message::EraserModeChanged, - ); - - let mut column = column![text("Drawing Defaults").size(20)].spacing(12); - - if show_color { - column = column - .push(drawing_color_block(self)) - .push(quick_colors_block(self)); - } - - if show_defaults { - column = column - .push( - row![ - labeled_input_with_feedback( - "Thickness (px)", - &self.draft.drawing_default_thickness, - &self.defaults.drawing_default_thickness, - TextField::DrawingThickness, - Some("Range: 1-50 px"), - validate_f64_range(&self.draft.drawing_default_thickness, 1.0, 50.0), - ), - labeled_input_with_feedback( - "Font size (pt)", - &self.draft.drawing_default_font_size, - &self.defaults.drawing_default_font_size, - TextField::DrawingFontSize, - Some("Range: 8-72 pt"), - validate_f64_range(&self.draft.drawing_default_font_size, 8.0, 72.0), - ), - labeled_input_with_feedback( - "Polygon sides", - &self.draft.drawing_polygon_sides, - &self.defaults.drawing_polygon_sides, - TextField::DrawingPolygonSides, - Some("Range: 3-12"), - validate_usize_range(&self.draft.drawing_polygon_sides, 3, 12), - ) - ] - .spacing(12), - ) - .push( - row![ - labeled_input_with_feedback( - "Eraser size (px)", - &self.draft.drawing_default_eraser_size, - &self.defaults.drawing_default_eraser_size, - TextField::DrawingEraserSize, - Some("Range: 1-50 px"), - validate_f64_range(&self.draft.drawing_default_eraser_size, 1.0, 50.0), - ), - labeled_control( - "Eraser mode", - eraser_mode_pick.width(Length::Fill).into(), - self.defaults - .drawing_default_eraser_mode - .label() - .to_string(), - self.draft.drawing_default_eraser_mode - != self.defaults.drawing_default_eraser_mode, - ) - ] - .spacing(12), - ) - .push( - row![ - labeled_input_with_feedback( - "Marker opacity (0.05-0.9)", - &self.draft.drawing_marker_opacity, - &self.defaults.drawing_marker_opacity, - TextField::DrawingMarkerOpacity, - None, - validate_f64_range(&self.draft.drawing_marker_opacity, 0.05, 0.9), - ), - labeled_input_with_feedback( - "Undo stack limit", - &self.draft.drawing_undo_stack_limit, - &self.defaults.drawing_undo_stack_limit, - TextField::DrawingUndoStackLimit, - Some("Range: 10-1000"), - validate_usize_range(&self.draft.drawing_undo_stack_limit, 10, 1000), - ) - ] - .spacing(12), - ) - .push( - row![ - labeled_input_with_feedback( - "Hit-test tolerance (px)", - &self.draft.drawing_hit_test_tolerance, - &self.defaults.drawing_hit_test_tolerance, - TextField::DrawingHitTestTolerance, - Some("Range: 1-20 px"), - validate_f64_range(&self.draft.drawing_hit_test_tolerance, 1.0, 20.0), - ), - labeled_input_with_feedback( - "Hit-test threshold", - &self.draft.drawing_hit_test_linear_threshold, - &self.defaults.drawing_hit_test_linear_threshold, - TextField::DrawingHitTestThreshold, - Some("Minimum: 1"), - validate_usize_min(&self.draft.drawing_hit_test_linear_threshold, 1), - ) - ] - .spacing(12), - ) - .push(toggle_row( - "Enable text background", - self.draft.drawing_text_background_enabled, - self.defaults.drawing_text_background_enabled, - ToggleField::DrawingTextBackground, - )) - .push(toggle_row( - "Start shapes filled", - self.draft.drawing_default_fill_enabled, - self.defaults.drawing_default_fill_enabled, - ToggleField::DrawingFillEnabled, - )); - } - - if show_drag { - column = column.push(self.drag_mapping_block(search)); - } - - if show_font { - column = column.push(font_controls(self)); - } - - let column = column.width(Length::Fill); - - scrollable(column).id(CONTENT_SCROLL_ID).into() - } - - fn drag_mapping_block(&self, search: Option<&TabSearchSummary>) -> Element<'_, Message> { - let section_button = |mouse_button: DragMouseButton| { - button(mouse_button.label()) - .style(if self.active_drawing_drag_button == Some(mouse_button) { - theme::Button::Primary - } else { - theme::Button::Secondary - }) - .on_press(Message::DrawingDragMappingSectionToggled(mouse_button)) - }; - - let mut column = column![ - text("Drag Tool Mapping").size(16), - row![ - section_button(DragMouseButton::Left), - section_button(DragMouseButton::Right), - section_button(DragMouseButton::Middle), - ] - .spacing(8) - ] - .spacing(8); - - for mouse_button in self.visible_drag_mapping_buttons(search) { - let (current, defaults) = match mouse_button { - DragMouseButton::Left => ( - &self.draft.drawing_drag_tools.left, - &self.defaults.drawing_drag_tools.left, - ), - DragMouseButton::Right => ( - &self.draft.drawing_drag_tools.right, - &self.defaults.drawing_drag_tools.right, - ), - DragMouseButton::Middle => ( - &self.draft.drawing_drag_tools.middle, - &self.defaults.drawing_drag_tools.middle, - ), - }; - - column = column.push(drag_button_controls(mouse_button, current, defaults)); - } - - column.into() - } - - fn visible_drag_mapping_buttons( - &self, - search: Option<&TabSearchSummary>, - ) -> Vec { - if search.is_some_and(|search| { - !search.show_all() && search.area_matches(SearchArea::DrawingDragTools) - }) { - return vec![ - DragMouseButton::Left, - DragMouseButton::Right, - DragMouseButton::Middle, - ]; - } - - self.active_drawing_drag_button.into_iter().collect() - } -} - -fn drag_button_controls<'a>( - button: DragMouseButton, - current: &DragButtonConfig, - defaults: &DragButtonConfig, -) -> Element<'a, Message> { - column![ - row![ - drag_binding_control(button, DragToolField::Drag, current, defaults,), - drag_binding_control(button, DragToolField::ShiftDrag, current, defaults,) - ] - .spacing(12), - row![ - drag_binding_control(button, DragToolField::CtrlDrag, current, defaults,), - drag_binding_control(button, DragToolField::CtrlShiftDrag, current, defaults,) - ] - .spacing(12), - row![drag_binding_control( - button, - DragToolField::TabDrag, - current, - defaults, - )] - .spacing(12) - ] - .spacing(8) - .into() -} - -fn drag_binding_control<'a>( - button: DragMouseButton, - field: DragToolField, - current: &DragButtonConfig, - defaults: &DragButtonConfig, -) -> Element<'a, Message> { - let selected = drag_tool_for_field(current, field); - let default = drag_tool_for_field(defaults, field); - let color = drag_color_for_field(current, field); - let default_color = drag_color_for_field(defaults, field); - column![ - labeled_control( - field.label(), - pick_list( - DragToolOption::list_for_button(button), - Some(selected), - move |option| { Message::DrawingMouseDragToolChanged(button, field, option) } - ) - .width(Length::Fill) - .into(), - default.label().to_string(), - selected != default, - ), - labeled_control( - "Color", - pick_list(DragColorOption::list(), Some(color), move |option| { - Message::DrawingMouseDragColorChanged(button, field, option) - }) - .width(Length::Fill) - .into(), - default_color.label().to_string(), - color != default_color, - ) - ] - .spacing(6) - .into() -} - -fn drag_tool_for_field(config: &DragButtonConfig, field: DragToolField) -> DragToolOption { - match field { - DragToolField::Drag => DragToolOption::from_drag_tool(config.drag_tool), - DragToolField::ShiftDrag => DragToolOption::from_drag_tool(config.shift_drag_tool), - DragToolField::CtrlDrag => DragToolOption::from_drag_tool(config.ctrl_drag_tool), - DragToolField::CtrlShiftDrag => DragToolOption::from_drag_tool(config.ctrl_shift_drag_tool), - DragToolField::TabDrag => DragToolOption::from_drag_tool(config.tab_drag_tool), - } -} - -fn drag_color_for_field(config: &DragButtonConfig, field: DragToolField) -> DragColorOption { - match field { - DragToolField::Drag => DragColorOption::from_color(config.drag_color.as_ref()), - DragToolField::ShiftDrag => DragColorOption::from_color(config.shift_drag_color.as_ref()), - DragToolField::CtrlDrag => DragColorOption::from_color(config.ctrl_drag_color.as_ref()), - DragToolField::CtrlShiftDrag => { - DragColorOption::from_color(config.ctrl_shift_drag_color.as_ref()) - } - DragToolField::TabDrag => DragColorOption::from_color(config.tab_drag_color.as_ref()), - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::models::{SearchQuery, TabId}; - - #[test] - fn drag_tool_search_expands_all_drag_button_mappings() { - let (mut app, _task) = ConfiguratorApp::new_app(); - app.search_query = SearchQuery::new("shift"); - - let search = app.search_summary(); - let drawing = search.tab(TabId::Drawing).expect("drawing match"); - - assert_eq!(app.active_drawing_drag_button, None); - assert_eq!( - app.visible_drag_mapping_buttons(Some(drawing)), - vec![ - DragMouseButton::Left, - DragMouseButton::Right, - DragMouseButton::Middle, - ], - ); - assert_eq!(app.active_drawing_drag_button, None); - } -} diff --git a/configurator/src/app/view/history.rs b/configurator/src/app/view/history.rs deleted file mode 100644 index e963588a..00000000 --- a/configurator/src/app/view/history.rs +++ /dev/null @@ -1,142 +0,0 @@ -use crate::app::view::theme; -use iced::Element; -use iced::widget::{column, container, row, scrollable, text}; - -use crate::app::scroll::CONTENT_SCROLL_ID; -use crate::messages::Message; -use crate::models::{TextField, ToggleField}; - -use super::super::search::{SearchArea, TabSearchSummary}; -use super::super::state::ConfiguratorApp; -use super::widgets::{ - labeled_input_state, labeled_input_with_feedback, toggle_row, validate_u64_range, - validate_usize_range, -}; - -impl ConfiguratorApp { - pub(super) fn history_tab(&self, search: Option<&TabSearchSummary>) -> Element<'_, Message> { - let show_main = search.is_none_or(|search| search.area_matches(SearchArea::HistoryMain)); - let show_custom = - search.is_none_or(|search| search.area_matches(SearchArea::HistoryCustom)); - let show_custom_toggle = custom_toggle_visible(show_main, show_custom); - let custom_enabled = self.draft.history_custom_section_enabled; - let custom_section = container( - column![ - text("Custom section").size(16), - row![ - labeled_input_state( - "Custom undo delay (ms)", - &self.draft.history_custom_undo_delay_ms, - &self.defaults.history_custom_undo_delay_ms, - TextField::HistoryCustomUndoDelayMs, - custom_enabled, - Some("Range: 50-5000 ms"), - if custom_enabled { - validate_u64_range(&self.draft.history_custom_undo_delay_ms, 50, 5000) - } else { - None - }, - ), - labeled_input_state( - "Custom redo delay (ms)", - &self.draft.history_custom_redo_delay_ms, - &self.defaults.history_custom_redo_delay_ms, - TextField::HistoryCustomRedoDelayMs, - custom_enabled, - Some("Range: 50-5000 ms"), - if custom_enabled { - validate_u64_range(&self.draft.history_custom_redo_delay_ms, 50, 5000) - } else { - None - }, - ) - ] - .spacing(12), - row![ - labeled_input_state( - "Custom undo steps", - &self.draft.history_custom_undo_steps, - &self.defaults.history_custom_undo_steps, - TextField::HistoryCustomUndoSteps, - custom_enabled, - Some("Range: 1-500"), - if custom_enabled { - validate_usize_range(&self.draft.history_custom_undo_steps, 1, 500) - } else { - None - }, - ), - labeled_input_state( - "Custom redo steps", - &self.draft.history_custom_redo_steps, - &self.defaults.history_custom_redo_steps, - TextField::HistoryCustomRedoSteps, - custom_enabled, - Some("Range: 1-500"), - if custom_enabled { - validate_usize_range(&self.draft.history_custom_redo_steps, 1, 500) - } else { - None - }, - ) - ] - .spacing(12), - ] - .spacing(12), - ) - .padding(12) - .style(theme::Container::Box); - - let mut content = column![text("History").size(20)].spacing(12); - if show_main { - content = content.push( - row![ - labeled_input_with_feedback( - "Undo all delay (ms)", - &self.draft.history_undo_all_delay_ms, - &self.defaults.history_undo_all_delay_ms, - TextField::HistoryUndoAllDelayMs, - Some("Range: 50-5000 ms"), - validate_u64_range(&self.draft.history_undo_all_delay_ms, 50, 5000), - ), - labeled_input_with_feedback( - "Redo all delay (ms)", - &self.draft.history_redo_all_delay_ms, - &self.defaults.history_redo_all_delay_ms, - TextField::HistoryRedoAllDelayMs, - Some("Range: 50-5000 ms"), - validate_u64_range(&self.draft.history_redo_all_delay_ms, 50, 5000), - ) - ] - .spacing(12), - ); - } - if show_custom_toggle { - content = content.push(toggle_row( - "Enable custom undo/redo section", - self.draft.history_custom_section_enabled, - self.defaults.history_custom_section_enabled, - ToggleField::HistoryCustomSectionEnabled, - )); - } - if show_custom { - content = content.push(custom_section); - } - - scrollable(content).id(CONTENT_SCROLL_ID).into() - } -} - -fn custom_toggle_visible(show_main: bool, show_custom: bool) -> bool { - show_main || show_custom -} - -#[cfg(test)] -mod tests { - use super::custom_toggle_visible; - - #[test] - fn custom_search_results_keep_custom_toggle_visible() { - assert!(custom_toggle_visible(false, true)); - } -} diff --git a/configurator/src/app/view/keybindings.rs b/configurator/src/app/view/keybindings.rs deleted file mode 100644 index 1173b0a1..00000000 --- a/configurator/src/app/view/keybindings.rs +++ /dev/null @@ -1,119 +0,0 @@ -use crate::app::view::theme; -use iced::alignment::Horizontal; -use iced::widget::{Column, Row, button, column, container, row, scrollable, text, text_input}; -use iced::{Element, Length}; - -use crate::app::scroll::CONTENT_SCROLL_ID; -use crate::messages::Message; -use crate::models::KeybindingsTabId; - -use super::super::search::TabSearchSummary; -use super::super::state::ConfiguratorApp; -use super::widgets::{LABEL_COLUMN_WIDTH, default_value_text}; - -impl ConfiguratorApp { - pub(super) fn keybindings_tab( - &self, - search: Option<&TabSearchSummary>, - ) -> Element<'_, Message> { - let tabs = visible_keybinding_tabs(search); - let active_tab = active_keybinding_tab(search, self.active_keybindings_tab); - let tab_bar = tabs.iter().fold( - Row::new().spacing(8).align_y(iced::Alignment::Center), - |row, tab| { - let label = tab.title(); - let button = button(label) - .padding([6, 12]) - .style(if Some(*tab) == active_tab { - theme::Button::Primary - } else { - theme::Button::Secondary - }) - .on_press(Message::KeybindingsTabSelected(*tab)); - row.push(button) - }, - ); - - let mut column = Column::new() - .spacing(8) - .push(text("Keybindings (comma-separated)").size(20)); - - if !tabs.is_empty() { - column = column.push(tab_bar); - } - - for entry in self - .draft - .keybindings - .entries - .iter() - .filter(|entry| keybinding_row_visible(search, active_tab, entry.field)) - { - let default_value = self - .defaults - .keybindings - .value_for(entry.field) - .unwrap_or(""); - let changed = entry.value.trim() != default_value.trim(); - column = column.push( - row![ - container(text(entry.field.label()).size(16)) - .width(Length::Fixed(LABEL_COLUMN_WIDTH)) - .align_x(Horizontal::Right), - column![ - text_input("Shortcut list", &entry.value) - .on_input({ - let field = entry.field; - move |value| Message::KeybindingChanged(field, value) - }) - .width(Length::Fill), - row![default_value_text(default_value.to_string(), changed)] - .align_y(iced::Alignment::Center) - ] - .spacing(4) - .width(Length::Fill) - ] - .spacing(12) - .align_y(iced::Alignment::Center), - ); - } - - scrollable(column).id(CONTENT_SCROLL_ID).into() - } -} - -fn visible_keybinding_tabs(search: Option<&TabSearchSummary>) -> Vec { - match search { - Some(summary) if !summary.show_all() => summary.keybinding_tabs().to_vec(), - _ => KeybindingsTabId::ALL.to_vec(), - } -} - -fn active_keybinding_tab( - search: Option<&TabSearchSummary>, - preferred: KeybindingsTabId, -) -> Option { - match search { - Some(summary) if summary.show_all() || summary.keybindings_tab_visible(preferred) => { - Some(preferred) - } - Some(summary) => summary.keybinding_tabs().first().copied(), - None => Some(preferred), - } -} - -fn keybinding_row_visible( - search: Option<&TabSearchSummary>, - active_tab: Option, - field: crate::models::KeybindingField, -) -> bool { - let Some(search) = search else { - return active_tab == Some(field.tab()); - }; - if search.show_all() { - return true; - } - active_tab == Some(field.tab()) - && (search.keybinding_field_visible(field) - || search.keybinding_tab_title_visible(field.tab())) -} diff --git a/configurator/src/app/view/mod.rs b/configurator/src/app/view/mod.rs deleted file mode 100644 index b0003c71..00000000 --- a/configurator/src/app/view/mod.rs +++ /dev/null @@ -1,319 +0,0 @@ -mod arrow; -mod boards; -mod capture; -mod daemon; -mod drawing; -mod history; -mod keybindings; -mod performance; -mod presets; -mod render_profiles; -mod session; -#[cfg(feature = "tablet-input")] -mod tablet; -pub(crate) mod theme; -mod ui; -mod widgets; - -use iced::widget::{Column, Row, Space, button, column, container, row, rule, text, text_input}; -use iced::{Element, Length}; -use wayscriber::config::{Config, MigrationPreview}; - -use crate::messages::Message; -use crate::models::TabId; - -use self::widgets::{default_label_color, feedback_text}; -use super::search::{AppSearchSummary, SEARCH_INPUT_ID, TabSearchSummary}; -use super::state::{ConfiguratorApp, StatusMessage}; - -const SEARCH_INPUT_WIDTH: f32 = 324.0; - -impl ConfiguratorApp { - pub(crate) fn view(&self) -> Element<'_, Message> { - let search = self.search_summary(); - let header = self.header_view(&search); - let content = self.tab_view(&search); - let footer = self.footer_view(); - - column![header, content, footer] - .spacing(12) - .padding(16) - .into() - } - - fn header_view(&self, search: &AppSearchSummary) -> Element<'_, Message> { - let reload_button = button("Reload") - .style(theme::Button::Secondary) - .on_press(Message::ReloadRequested); - - let defaults_button = if self.defaults_reset_pending { - button("Confirm Defaults") - .style(theme::Button::Warning) - .on_press(Message::ResetToDefaultsConfirmed) - } else { - button("Defaults") - .style(theme::Button::Warning) - .on_press(Message::ResetToDefaultsRequested) - }; - - let save_button = button("Save") - .style(theme::Button::Primary) - .on_press(Message::SaveRequested); - - let search_input = text_input("Search settings", search.raw_query()) - .id(SEARCH_INPUT_ID) - .on_input(Message::SearchChanged) - .padding(8) - .width(Length::Fixed(SEARCH_INPUT_WIDTH)); - - let mut toolbar = Row::new() - .spacing(12) - .align_y(iced::Alignment::Center) - .push(reload_button) - .push(defaults_button) - .push(save_button) - .push(search_input); - - if search.has_raw_input() { - if search.is_active() { - toolbar = - toolbar.push(text(format!("{} matches", search.total_matches())).size(14)); - } - toolbar = toolbar.push( - button("Clear") - .style(theme::Button::Subtle) - .on_press(Message::SearchCleared), - ); - } else { - toolbar = toolbar.push( - button("Find") - .style(theme::Button::Subtle) - .on_press(Message::SearchFocusRequested), - ); - } - - if self.defaults_reset_pending { - toolbar = toolbar.push( - button("Cancel") - .style(theme::Button::Subtle) - .on_press(Message::ResetToDefaultsCanceled), - ); - } - - toolbar = if self.is_saving { - toolbar.push(text("Saving...").size(16)) - } else if self.is_loading { - toolbar.push(text("Loading...").size(16)) - } else if self.is_dirty { - toolbar.push( - text("Unsaved changes") - .style(theme::Text::Color(iced::Color::from_rgb(0.95, 0.72, 0.2))), - ) - } else { - toolbar.push( - text("No unsaved changes") - .style(theme::Text::Color(iced::Color::from_rgb(0.6, 0.8, 0.6))), - ) - }; - - let toolbar = container(toolbar) - .padding([6, 8]) - .width(Length::Fill) - .style(theme::Container::ActionBar); - - let banner: Element<'_, Message> = match &self.status { - StatusMessage::Idle => Space::new() - .width(Length::Shrink) - .height(Length::Shrink) - .into(), - StatusMessage::Info(message) => container(text(message)) - .padding(8) - .style(theme::Container::Box) - .into(), - StatusMessage::Success(message) => container( - text(message).style(theme::Text::Color(iced::Color::from_rgb(0.6, 0.9, 0.6))), - ) - .padding(8) - .style(theme::Container::Box) - .into(), - StatusMessage::Error(message) => container( - text(message).style(theme::Text::Color(iced::Color::from_rgb(1.0, 0.5, 0.5))), - ) - .padding(8) - .style(theme::Container::Box) - .into(), - StatusMessage::Warning(message) => container(text(message)) - .padding(8) - .style(theme::Container::Warning) - .into(), - }; - - let mut header = column![toolbar, banner].spacing(8); - if let Some(preview) = self.pending_migration() { - header = header.push(migration_view(preview)); - } - - header.into() - } - - fn tab_view(&self, search: &AppSearchSummary) -> Element<'_, Message> { - let tabs = visible_tabs(search); - let active_tab = search.active_tab_or_first(self.active_tab); - let tab_bar = tabs.iter().fold( - Row::new().spacing(8).align_y(iced::Alignment::Center), - |row, tab| { - let label = tab.title(); - let button = button(label) - .padding([6, 12]) - .style(if Some(*tab) == active_tab { - theme::Button::TabActive - } else { - theme::Button::TabInactive - }) - .on_press(Message::TabSelected(*tab)); - row.push(button) - }, - ); - - let content: Element<'_, Message> = match active_tab { - Some(TabId::Drawing) => self.drawing_tab(search.tab(TabId::Drawing)), - Some(TabId::Presets) => self.presets_tab(search.tab(TabId::Presets)), - Some(TabId::Arrow) => self.arrow_tab(search.tab(TabId::Arrow)), - Some(TabId::History) => self.history_tab(search.tab(TabId::History)), - Some(TabId::Performance) => self.performance_tab(search.tab(TabId::Performance)), - Some(TabId::Ui) => self.ui_tab(search.tab(TabId::Ui)), - Some(TabId::Boards) => self.boards_tab(search.tab(TabId::Boards)), - Some(TabId::RenderProfiles) => { - self.render_profiles_tab(search.tab(TabId::RenderProfiles)) - } - Some(TabId::Capture) => self.capture_tab(search.tab(TabId::Capture)), - Some(TabId::Daemon) => self.daemon_tab(search.tab(TabId::Daemon)), - Some(TabId::Session) => self.session_tab(search.tab(TabId::Session)), - Some(TabId::Keybindings) => self.keybindings_tab(search.tab(TabId::Keybindings)), - #[cfg(feature = "tablet-input")] - Some(TabId::Tablet) => self.tablet_tab(search.tab(TabId::Tablet)), - None => empty_search_view(), - }; - - let legend = self.defaults_legend(); - - column![tab_bar, rule::horizontal(2), legend, content] - .spacing(12) - .into() - } - - fn footer_view(&self) -> Element<'_, Message> { - let mut info = Column::new().spacing(4); - - let config_path = self - .base_document - .as_ref() - .map(|document| document.source_path().to_path_buf()) - .or_else(|| Config::get_config_path().ok()); - if let Some(path) = config_path { - info = info.push(text(format!("Config path: {}", path.display())).size(14)); - } - if let Some(path) = &self.last_backup_path { - info = info.push(text(format!("Last backup: {}", path.display())).size(14)); - } - - info.into() - } - - fn defaults_legend(&self) -> Element<'_, Message> { - let legend = row![ - text("Default labels:").size(12), - text("blue = matches") - .size(12) - .style(theme::Text::Color(default_label_color(false))), - text("yellow = changed") - .size(12) - .style(theme::Text::Color(default_label_color(true))), - ] - .spacing(12) - .align_y(iced::Alignment::Center); - - let hint = feedback_text( - "Tip: use Tab/Shift+Tab to move between fields; Enter activates buttons.", - false, - ); - - column![legend, hint].spacing(4).into() - } -} - -fn visible_tabs(search: &AppSearchSummary) -> Vec { - if search.is_active() { - search.tabs().iter().map(TabSearchSummary::tab).collect() - } else { - TabId::ALL.to_vec() - } -} - -/// The migration offer, with its whole change list in view. -/// -/// The list is not behind a Review button: a recipe proposes at most a handful -/// of shortcuts, and putting Apply next to something the user has not read yet -/// is the one thing this flow exists to avoid. -fn migration_view<'a>(preview: &'a MigrationPreview) -> Element<'a, Message> { - let mut body = Column::new() - .spacing(6) - .push(text("Configuration update available").size(16)) - .push( - text(format!( - "Shortcut defaults changed since this configuration was written. Applying updates this draft only; nothing reaches the file until you press Save, which also records revision {}.", - preview.proposed_revision() - )) - .size(14), - ); - - for change in preview.changes() { - body = body.push( - text(format!( - "{} ({}): {} → {}", - change.action_label(), - change.config_key(), - binding_summary(change.before()), - binding_summary(change.after()), - )) - .size(14), - ); - } - - let actions = row![ - button("Apply Update") - .style(theme::Button::Primary) - .on_press(Message::MigrationApplyRequested), - button("Dismiss") - .style(theme::Button::Subtle) - .on_press(Message::MigrationDismissed), - ] - .spacing(8) - .align_y(iced::Alignment::Center); - - container(body.push(actions)) - .padding(8) - .width(Length::Fill) - .style(theme::Container::Warning) - .into() -} - -fn binding_summary(bindings: &[String]) -> String { - if bindings.is_empty() { - return "unbound".to_string(); - } - bindings.join(", ") -} - -fn empty_search_view<'a>() -> Element<'a, Message> { - container( - column![ - text("No settings match this search.").size(20), - text("Try a field label, tab name, shortcut, or session path.").size(14), - ] - .spacing(8), - ) - .padding(16) - .style(theme::Container::Box) - .into() -} diff --git a/configurator/src/app/view/performance.rs b/configurator/src/app/view/performance.rs deleted file mode 100644 index 81aa84bc..00000000 --- a/configurator/src/app/view/performance.rs +++ /dev/null @@ -1,106 +0,0 @@ -use iced::widget::{column, pick_list, row, scrollable, text}; -use iced::{Element, Length}; -use wayscriber::config::{PerformanceFieldId, performance_field_metadata}; - -use crate::app::scroll::CONTENT_SCROLL_ID; -use crate::messages::Message; -use crate::models::{TextField, ToggleField}; - -use super::super::search::{SearchArea, TabSearchSummary}; -use super::super::state::ConfiguratorApp; -use super::widgets::{ - BUFFER_PICKER_WIDTH, labeled_control, labeled_input_with_feedback, toggle_row, - validate_u32_range, -}; - -impl ConfiguratorApp { - pub(super) fn performance_tab( - &self, - search: Option<&TabSearchSummary>, - ) -> Element<'_, Message> { - let buffer_metadata = performance_field_metadata(PerformanceFieldId::BufferCount); - let vsync_metadata = performance_field_metadata(PerformanceFieldId::EnableVsync); - let max_fps_metadata = performance_field_metadata(PerformanceFieldId::MaxFpsNoVsync); - let animation_metadata = performance_field_metadata(PerformanceFieldId::UiAnimationFps); - let buffer_counts = buffer_metadata - .constraint - .unsigned_choices() - .expect("buffer count metadata must declare choices"); - let max_fps_range = max_fps_metadata - .constraint - .unsigned_range() - .expect("max FPS metadata must declare a range"); - let animation_range = animation_metadata - .constraint - .unsigned_range() - .expect("animation FPS metadata must declare a range"); - let buffer_pick = pick_list( - buffer_counts, - Some(self.draft.performance_buffer_count), - Message::BufferCountChanged, - ); - let buffer_control = row![ - buffer_pick.width(Length::Fixed(BUFFER_PICKER_WIDTH)), - text(self.draft.performance_buffer_count.to_string()) - ] - .spacing(12) - .align_y(iced::Alignment::Center) - .into(); - - let show_rendering = - search.is_none_or(|search| search.area_matches(SearchArea::PerformanceRendering)); - let show_animation = - search.is_none_or(|search| search.area_matches(SearchArea::PerformanceAnimations)); - let mut content = column![text("Performance").size(20)].spacing(12); - - if show_rendering { - content = content - .push(text("Rendering").size(16)) - .push(labeled_control( - buffer_metadata.label, - buffer_control, - self.defaults.performance_buffer_count.to_string(), - self.draft.performance_buffer_count != self.defaults.performance_buffer_count, - )) - .push(text(buffer_metadata.help).size(12)) - .push(toggle_row( - vsync_metadata.label, - self.draft.performance_enable_vsync, - self.defaults.performance_enable_vsync, - ToggleField::PerformanceVsync, - )) - .push(text(vsync_metadata.help).size(12)) - .push(labeled_input_with_feedback( - max_fps_metadata.label, - &self.draft.performance_max_fps_no_vsync, - &self.defaults.performance_max_fps_no_vsync, - TextField::PerformanceMaxFpsNoVsync, - Some(max_fps_metadata.help), - validate_u32_range( - &self.draft.performance_max_fps_no_vsync, - max_fps_range.0, - max_fps_range.1, - ), - )); - } - - if show_animation { - content = content - .push(text("Animations").size(16)) - .push(labeled_input_with_feedback( - animation_metadata.label, - &self.draft.performance_ui_animation_fps, - &self.defaults.performance_ui_animation_fps, - TextField::PerformanceUiAnimationFps, - Some(animation_metadata.help), - validate_u32_range( - &self.draft.performance_ui_animation_fps, - animation_range.0, - animation_range.1, - ), - )); - } - - scrollable(content).id(CONTENT_SCROLL_ID).into() - } -} diff --git a/configurator/src/app/view/presets/mod.rs b/configurator/src/app/view/presets/mod.rs deleted file mode 100644 index 3077d045..00000000 --- a/configurator/src/app/view/presets/mod.rs +++ /dev/null @@ -1,62 +0,0 @@ -use iced::widget::{Column, pick_list, scrollable, text}; -use iced::{Element, Length}; -use wayscriber::config::{PRESET_SLOTS_MAX, PRESET_SLOTS_MIN}; - -use crate::app::scroll::CONTENT_SCROLL_ID; -use crate::messages::Message; - -use super::super::search::{SearchArea, TabSearchSummary}; -use super::super::state::ConfiguratorApp; -use super::widgets::{SMALL_PICKER_WIDTH, labeled_control}; - -mod slot; - -impl ConfiguratorApp { - pub(super) fn presets_tab(&self, search: Option<&TabSearchSummary>) -> Element<'_, Message> { - let show_all = search.is_none_or(TabSearchSummary::show_all); - let show_controls = - search.is_none_or(|search| search.area_matches(SearchArea::PresetControls)); - let slot_counts: Vec = (PRESET_SLOTS_MIN..=PRESET_SLOTS_MAX).collect(); - let slot_picker = pick_list( - slot_counts, - Some(self.draft.presets.slot_count), - Message::PresetSlotCountChanged, - ) - .width(Length::Fixed(SMALL_PICKER_WIDTH)); - - let slot_count_control = labeled_control( - "Visible slots", - slot_picker.into(), - self.defaults.presets.slot_count.to_string(), - self.draft.presets.slot_count != self.defaults.presets.slot_count, - ); - - let mut column = Column::new() - .spacing(12) - .push(text("Preset Slots").size(20)); - - if show_controls || show_all { - column = column.push(slot_count_control); - } - - let slot_limit = self - .draft - .presets - .slot_count - .clamp(PRESET_SLOTS_MIN, PRESET_SLOTS_MAX); - let slots: Vec = if show_all { - (1..=slot_limit).collect() - } else { - search - .map(TabSearchSummary::preset_slots) - .unwrap_or_default() - .to_vec() - }; - - for slot_index in slots { - column = column.push(self.preset_slot_section_for_search(slot_index, !show_all)); - } - - scrollable(column).id(CONTENT_SCROLL_ID).into() - } -} diff --git a/configurator/src/app/view/presets/slot/color.rs b/configurator/src/app/view/presets/slot/color.rs deleted file mode 100644 index ac475f11..00000000 --- a/configurator/src/app/view/presets/slot/color.rs +++ /dev/null @@ -1,137 +0,0 @@ -use crate::app::view::theme; -use iced::widget::{Column, Space, button, column, pick_list, row, text, text_input}; -use iced::{Element, Length}; - -use crate::messages::Message; -use crate::models::{ColorMode, NamedColorOption, PresetTextField}; - -use super::super::super::widgets::{ - COLOR_PICKER_WIDTH, DEFAULT_LABEL_GAP, color_preview_labeled, default_value_text, -}; -use crate::app::state::ConfiguratorApp; - -impl ConfiguratorApp { - pub(super) fn preset_slot_color_block(&self, slot_index: usize) -> Element<'_, Message> { - let Some(slot) = self.draft.presets.slot(slot_index) else { - return Space::new() - .width(Length::Shrink) - .height(Length::Shrink) - .into(); - }; - let Some(default_slot) = self.defaults.presets.slot(slot_index) else { - return Space::new() - .width(Length::Shrink) - .height(Length::Shrink) - .into(); - }; - - let color_mode_picker = row![ - button("Named Color") - .style(if slot.color.mode == ColorMode::Named { - theme::Button::Primary - } else { - theme::Button::Secondary - }) - .on_press(Message::PresetColorModeChanged( - slot_index, - ColorMode::Named, - )), - button("RGB Color") - .style(if slot.color.mode == ColorMode::Rgb { - theme::Button::Primary - } else { - theme::Button::Secondary - }) - .on_press(Message::PresetColorModeChanged(slot_index, ColorMode::Rgb)), - ] - .spacing(12); - - let color_section: Element<'_, Message> = match slot.color.mode { - ColorMode::Named => { - let picker = pick_list( - NamedColorOption::list(), - Some(slot.color.selected_named), - move |opt| Message::PresetNamedColorSelected(slot_index, opt), - ) - .width(Length::Fixed(COLOR_PICKER_WIDTH)); - - let picker_row = row![picker, color_preview_labeled(slot.color.preview_color()),] - .spacing(8) - .align_y(iced::Alignment::Center); - - let mut column = Column::new().spacing(8).push(picker_row); - - if slot.color.selected_named_is_custom() { - column = column.push( - text_input("Custom color name", &slot.color.name) - .on_input(move |value| { - Message::PresetTextChanged( - slot_index, - PresetTextField::ColorName, - value, - ) - }) - .width(Length::Fill), - ); - - if slot.color.preview_color().is_none() && !slot.color.name.trim().is_empty() { - column = column.push( - text("Unknown color name") - .size(12) - .style(theme::Text::Color(iced::Color::from_rgb(0.95, 0.6, 0.6))), - ); - } - } - - column.into() - } - ColorMode::Rgb => { - let rgb_inputs = row![ - text_input("R (0-255)", &slot.color.rgb[0]).on_input(move |value| { - Message::PresetColorComponentChanged(slot_index, 0, value) - }), - text_input("G (0-255)", &slot.color.rgb[1]).on_input(move |value| { - Message::PresetColorComponentChanged(slot_index, 1, value) - }), - text_input("B (0-255)", &slot.color.rgb[2]).on_input(move |value| { - Message::PresetColorComponentChanged(slot_index, 2, value) - }), - color_preview_labeled(slot.color.preview_color()), - ] - .spacing(8) - .align_y(iced::Alignment::Center); - - let mut column = Column::new().spacing(8).push(rgb_inputs); - - if slot.color.preview_color().is_none() - && slot.color.rgb.iter().any(|value| !value.trim().is_empty()) - { - column = column.push( - text("RGB values must be between 0 and 255") - .size(12) - .style(theme::Text::Color(iced::Color::from_rgb(0.95, 0.6, 0.6))), - ); - } - - column.into() - } - }; - - let color_block = column![ - row![ - text("Color").size(14), - default_value_text( - default_slot.color.summary(), - slot.color != default_slot.color - ), - ] - .spacing(DEFAULT_LABEL_GAP) - .align_y(iced::Alignment::Center), - color_mode_picker, - color_section - ] - .spacing(8); - - color_block.into() - } -} diff --git a/configurator/src/app/view/presets/slot/header.rs b/configurator/src/app/view/presets/slot/header.rs deleted file mode 100644 index a62c1cca..00000000 --- a/configurator/src/app/view/presets/slot/header.rs +++ /dev/null @@ -1,70 +0,0 @@ -use crate::app::view::theme; -use iced::widget::{Row, Space, button, checkbox, row, text}; -use iced::{Element, Length}; -use wayscriber::config::PRESET_SLOTS_MAX; - -use crate::messages::Message; - -use super::super::super::widgets::{DEFAULT_LABEL_GAP, bool_label, default_value_text}; -use crate::app::state::ConfiguratorApp; - -impl ConfiguratorApp { - pub(super) fn preset_slot_enabled_row(&self, slot_index: usize) -> Element<'_, Message> { - let Some(slot) = self.draft.presets.slot(slot_index) else { - return Space::new() - .width(Length::Shrink) - .height(Length::Shrink) - .into(); - }; - let Some(default_slot) = self.defaults.presets.slot(slot_index) else { - return Space::new() - .width(Length::Shrink) - .height(Length::Shrink) - .into(); - }; - - let enabled_row = row![ - checkbox(slot.enabled) - .label("Enabled") - .on_toggle(move |val| Message::PresetSlotEnabledChanged(slot_index, val)), - default_value_text( - bool_label(default_slot.enabled), - slot.enabled != default_slot.enabled - ), - ] - .spacing(DEFAULT_LABEL_GAP) - .align_y(iced::Alignment::Center); - - enabled_row.into() - } - - pub(super) fn preset_slot_header(&self, slot_index: usize) -> Element<'_, Message> { - let is_collapsed = self - .preset_collapsed - .get(slot_index.saturating_sub(1)) - .copied() - .unwrap_or(false); - let collapse_label = if is_collapsed { "Expand" } else { "Collapse" }; - let collapse_button = button(collapse_label) - .style(theme::Button::Secondary) - .on_press(Message::PresetCollapseToggled(slot_index)); - let reset_button = button("Reset") - .style(theme::Button::Secondary) - .on_press(Message::PresetResetSlot(slot_index)); - let mut duplicate_button = button("Duplicate").style(theme::Button::Secondary); - if slot_index < PRESET_SLOTS_MAX { - duplicate_button = duplicate_button.on_press(Message::PresetDuplicateSlot(slot_index)); - } - - let slot_header = Row::new() - .spacing(8) - .align_y(iced::Alignment::Center) - .push(text(format!("Slot {slot_index} settings")).size(18)) - .push(Space::new().width(Length::Fill).height(Length::Shrink)) - .push(collapse_button) - .push(reset_button) - .push(duplicate_button); - - slot_header.into() - } -} diff --git a/configurator/src/app/view/presets/slot/mod.rs b/configurator/src/app/view/presets/slot/mod.rs deleted file mode 100644 index bb1033c9..00000000 --- a/configurator/src/app/view/presets/slot/mod.rs +++ /dev/null @@ -1,92 +0,0 @@ -mod color; -mod header; -mod rows; - -use crate::app::view::theme; -use iced::widget::{Column, Space, container, text}; -use iced::{Element, Length}; - -use crate::messages::Message; - -use crate::app::state::ConfiguratorApp; - -impl ConfiguratorApp { - pub(super) fn preset_slot_section_for_search( - &self, - slot_index: usize, - force_expanded: bool, - ) -> Element<'_, Message> { - let Some(slot) = self.draft.presets.slot(slot_index) else { - return Space::new() - .width(Length::Shrink) - .height(Length::Shrink) - .into(); - }; - if self.defaults.presets.slot(slot_index).is_none() { - return Space::new() - .width(Length::Shrink) - .height(Length::Shrink) - .into(); - } - if slot_index > self.draft.presets.slot_count { - return Space::new() - .width(Length::Shrink) - .height(Length::Shrink) - .into(); - } - - let enabled_row = self.preset_slot_enabled_row(slot_index); - let slot_header = self.preset_slot_header(slot_index); - - let mut section = Column::new().spacing(8).push(slot_header).push(enabled_row); - - let is_collapsed = self - .preset_collapsed - .get(slot_index.saturating_sub(1)) - .copied() - .unwrap_or(false) - && !force_expanded; - if is_collapsed { - return container(section) - .padding(12) - .style(theme::Container::Box) - .into(); - } - - if !slot.enabled { - section = section.push( - text("Slot disabled. Enable to configure.") - .size(12) - .style(theme::Text::Color(iced::Color::from_rgb(0.6, 0.6, 0.6))), - ); - return container(section) - .padding(12) - .style(theme::Container::Box) - .into(); - } - - let tool_row = self.preset_slot_tool_row(slot_index); - let color_block = self.preset_slot_color_block(slot_index); - let size_row = self.preset_slot_size_row(slot_index); - let eraser_row = self.preset_slot_eraser_row(slot_index); - let fill_row = self.preset_slot_fill_row(slot_index); - let font_row = self.preset_slot_font_row(slot_index); - let arrow_row = self.preset_slot_arrow_row(slot_index); - let status_row = self.preset_slot_status_row(slot_index); - - section = section - .push(tool_row) - .push(color_block) - .push(size_row) - .push(eraser_row) - .push(fill_row) - .push(font_row) - .push(arrow_row) - .push(status_row); - - container(section) - .padding(12) - .style(theme::Container::Box) - .into() - } -} diff --git a/configurator/src/app/view/presets/slot/rows.rs b/configurator/src/app/view/presets/slot/rows.rs deleted file mode 100644 index 17fc515a..00000000 --- a/configurator/src/app/view/presets/slot/rows.rs +++ /dev/null @@ -1,260 +0,0 @@ -use iced::widget::{Space, pick_list, row}; -use iced::{Element, Length}; - -use crate::messages::Message; -use crate::models::{ - PresetEraserKindOption, PresetEraserModeOption, PresetTextField, PresetToggleField, ToolOption, -}; - -use super::super::super::widgets::{labeled_control, preset_input, preset_override_control}; -use crate::app::state::ConfiguratorApp; - -impl ConfiguratorApp { - pub(super) fn preset_slot_tool_row(&self, slot_index: usize) -> Element<'_, Message> { - let Some(slot) = self.draft.presets.slot(slot_index) else { - return Space::new() - .width(Length::Shrink) - .height(Length::Shrink) - .into(); - }; - let Some(default_slot) = self.defaults.presets.slot(slot_index) else { - return Space::new() - .width(Length::Shrink) - .height(Length::Shrink) - .into(); - }; - - let tool_picker = pick_list(ToolOption::list(), Some(slot.tool), move |opt| { - Message::PresetToolChanged(slot_index, opt) - }) - .width(Length::Fill); - - row![ - preset_input( - "Label", - &slot.name, - &default_slot.name, - slot_index, - PresetTextField::Name, - true, - ), - labeled_control( - "Tool", - tool_picker.into(), - default_slot.tool.label(), - slot.tool != default_slot.tool, - ) - ] - .spacing(12) - .into() - } - - pub(super) fn preset_slot_size_row(&self, slot_index: usize) -> Element<'_, Message> { - let Some(slot) = self.draft.presets.slot(slot_index) else { - return Space::new() - .width(Length::Shrink) - .height(Length::Shrink) - .into(); - }; - let Some(default_slot) = self.defaults.presets.slot(slot_index) else { - return Space::new() - .width(Length::Shrink) - .height(Length::Shrink) - .into(); - }; - - row![ - preset_input( - "Size (px)", - &slot.size, - &default_slot.size, - slot_index, - PresetTextField::Size, - false, - ), - preset_input( - "Marker opacity (0.05-0.9)", - &slot.marker_opacity, - &default_slot.marker_opacity, - slot_index, - PresetTextField::MarkerOpacity, - true, - ) - ] - .spacing(12) - .into() - } - - pub(super) fn preset_slot_eraser_row(&self, slot_index: usize) -> Element<'_, Message> { - let Some(slot) = self.draft.presets.slot(slot_index) else { - return Space::new() - .width(Length::Shrink) - .height(Length::Shrink) - .into(); - }; - let Some(default_slot) = self.defaults.presets.slot(slot_index) else { - return Space::new() - .width(Length::Shrink) - .height(Length::Shrink) - .into(); - }; - - row![ - labeled_control( - "Eraser kind", - pick_list( - PresetEraserKindOption::list(), - Some(slot.eraser_kind), - move |opt| Message::PresetEraserKindChanged(slot_index, opt), - ) - .width(Length::Fill) - .into(), - default_slot.eraser_kind.label(), - slot.eraser_kind != default_slot.eraser_kind, - ), - labeled_control( - "Eraser mode", - pick_list( - PresetEraserModeOption::list(), - Some(slot.eraser_mode), - move |opt| Message::PresetEraserModeChanged(slot_index, opt), - ) - .width(Length::Fill) - .into(), - default_slot.eraser_mode.label(), - slot.eraser_mode != default_slot.eraser_mode, - ) - ] - .spacing(12) - .into() - } - - pub(super) fn preset_slot_fill_row(&self, slot_index: usize) -> Element<'_, Message> { - let Some(slot) = self.draft.presets.slot(slot_index) else { - return Space::new() - .width(Length::Shrink) - .height(Length::Shrink) - .into(); - }; - let Some(default_slot) = self.defaults.presets.slot(slot_index) else { - return Space::new() - .width(Length::Shrink) - .height(Length::Shrink) - .into(); - }; - - row![ - preset_override_control( - "Fill enabled", - slot.fill_enabled, - default_slot.fill_enabled, - slot_index, - PresetToggleField::FillEnabled, - ), - preset_override_control( - "Text background", - slot.text_background_enabled, - default_slot.text_background_enabled, - slot_index, - PresetToggleField::TextBackgroundEnabled, - ) - ] - .spacing(12) - .into() - } - - pub(super) fn preset_slot_font_row(&self, slot_index: usize) -> Element<'_, Message> { - let Some(slot) = self.draft.presets.slot(slot_index) else { - return Space::new() - .width(Length::Shrink) - .height(Length::Shrink) - .into(); - }; - let Some(default_slot) = self.defaults.presets.slot(slot_index) else { - return Space::new() - .width(Length::Shrink) - .height(Length::Shrink) - .into(); - }; - - row![ - preset_input( - "Font size (pt)", - &slot.font_size, - &default_slot.font_size, - slot_index, - PresetTextField::FontSize, - true, - ), - preset_input( - "Arrow length (px)", - &slot.arrow_length, - &default_slot.arrow_length, - slot_index, - PresetTextField::ArrowLength, - true, - ) - ] - .spacing(12) - .into() - } - - pub(super) fn preset_slot_arrow_row(&self, slot_index: usize) -> Element<'_, Message> { - let Some(slot) = self.draft.presets.slot(slot_index) else { - return Space::new() - .width(Length::Shrink) - .height(Length::Shrink) - .into(); - }; - let Some(default_slot) = self.defaults.presets.slot(slot_index) else { - return Space::new() - .width(Length::Shrink) - .height(Length::Shrink) - .into(); - }; - - row![ - preset_input( - "Arrow angle (deg)", - &slot.arrow_angle, - &default_slot.arrow_angle, - slot_index, - PresetTextField::ArrowAngle, - true, - ), - preset_override_control( - "Arrow head at end", - slot.arrow_head_at_end, - default_slot.arrow_head_at_end, - slot_index, - PresetToggleField::ArrowHeadAtEnd, - ) - ] - .spacing(12) - .into() - } - - pub(super) fn preset_slot_status_row(&self, slot_index: usize) -> Element<'_, Message> { - let Some(slot) = self.draft.presets.slot(slot_index) else { - return Space::new() - .width(Length::Shrink) - .height(Length::Shrink) - .into(); - }; - let Some(default_slot) = self.defaults.presets.slot(slot_index) else { - return Space::new() - .width(Length::Shrink) - .height(Length::Shrink) - .into(); - }; - - row![preset_override_control( - "Show status bar", - slot.show_status_bar, - default_slot.show_status_bar, - slot_index, - PresetToggleField::ShowStatusBar, - )] - .into() - } -} diff --git a/configurator/src/app/view/render_profiles.rs b/configurator/src/app/view/render_profiles.rs deleted file mode 100644 index 8aa01eb2..00000000 --- a/configurator/src/app/view/render_profiles.rs +++ /dev/null @@ -1,291 +0,0 @@ -use iced::widget::{ - button, checkbox, column, container, pick_list, row, scrollable, text, text_input, -}; -use iced::{Alignment, Element, Length}; - -use crate::app::scroll::CONTENT_SCROLL_ID; -use crate::app::view::theme; -use crate::messages::Message; -use crate::models::color::rgb_to_hsv; -use crate::models::{ - ColorPickerId, RenderProfileExportOption, RenderProfileMappingSide, - RenderProfileSelectionOption, RenderProfileTextField, -}; -use wayscriber::render_profiles::parse_hex_rgb; - -use super::super::search::{SearchArea, TabSearchSummary}; -use super::super::state::ConfiguratorApp; -use super::widgets::{color_preview_badge, labeled_control, picker_panel}; - -impl ConfiguratorApp { - pub(super) fn render_profiles_tab( - &self, - search: Option<&TabSearchSummary>, - ) -> Element<'_, Message> { - let profiles = &self.draft.render_profiles; - let show_all = search.is_none_or(TabSearchSummary::show_all); - let show_general = - search.is_none_or(|search| search.area_matches(SearchArea::RenderProfilesGeneral)); - let profile_ids = profiles.profile_ids(); - let active_selection = - RenderProfileSelectionOption::from_active(&profiles.active, &profile_ids); - let export_selection = (!profiles.export_profile.is_empty() - && profile_ids.contains(&profiles.export_profile)) - .then_some(profiles.export_profile.clone()); - - let active_picker = pick_list( - RenderProfileSelectionOption::list(&profile_ids), - Some(active_selection), - |selection| Message::RenderProfileActiveChanged(selection.profile_id()), - ) - .width(Length::Fill); - - let export_picker = pick_list( - RenderProfileExportOption::list(), - Some(profiles.export), - Message::RenderProfileExportChanged, - ) - .width(Length::Fill); - - let mut content = column![text("Render Profiles").size(20)].spacing(12); - - if show_general || show_all { - content = content - .push( - row![ - checkbox(profiles.apply_to_canvas) - .label("Preview canvas") - .on_toggle(Message::RenderProfileApplyCanvasChanged), - checkbox(profiles.apply_to_ui) - .label("Preview UI") - .on_toggle(Message::RenderProfileApplyUiChanged), - ] - .spacing(16) - .align_y(Alignment::Center), - ) - .push(labeled_control( - "Startup profile", - active_picker.into(), - self.defaults.render_profiles.active.clone(), - profiles.active != self.defaults.render_profiles.active, - )) - .push(labeled_control( - "Canvas export profile", - export_picker.into(), - self.defaults.render_profiles.export.label().to_string(), - profiles.export != self.defaults.render_profiles.export, - )); - } - - if (show_general || show_all) && profiles.export == RenderProfileExportOption::Profile { - let picker = pick_list( - profile_ids, - export_selection, - Message::RenderProfileExportProfileChanged, - ) - .width(Length::Fill); - content = content.push(labeled_control( - "Named export profile", - picker.into(), - self.defaults.render_profiles.export_profile.clone(), - profiles.export_profile != self.defaults.render_profiles.export_profile, - )); - } - - if show_general || show_all { - content = content.push(button("Add profile").on_press(Message::RenderProfileAdd)); - } - - let indices: Vec = if show_all { - (0..profiles.profiles.len()).collect() - } else { - search - .map(TabSearchSummary::render_profile_indices) - .unwrap_or_default() - .to_vec() - }; - - for index in indices { - content = content.push(self.render_profile_section(index, search)); - } - - scrollable(content).id(CONTENT_SCROLL_ID).into() - } - - fn render_profile_section( - &self, - profile_index: usize, - search: Option<&TabSearchSummary>, - ) -> Element<'_, Message> { - let profile = &self.draft.render_profiles.profiles[profile_index]; - let show_all = search.is_none_or(TabSearchSummary::show_all); - let show_profile_controls = - search.is_none_or(|search| search.render_profile_controls_visible(profile_index)); - let header = row![ - text(if profile.name.trim().is_empty() { - "Profile" - } else { - profile.name.trim() - }) - .size(16), - button("Duplicate").on_press(Message::RenderProfileDuplicate(profile_index)), - button("Delete") - .style(theme::Button::Warning) - .on_press(Message::RenderProfileRemove(profile_index)), - ] - .spacing(8) - .align_y(Alignment::Center); - - let mut mappings = column![].spacing(8); - let mapping_indices: Vec = if show_all || show_profile_controls { - (0..profile.mappings.len()).collect() - } else { - search - .map(TabSearchSummary::render_profile_mapping_indices) - .unwrap_or_default() - .iter() - .filter_map(|(matched_profile, mapping)| { - (*matched_profile == profile_index).then_some(*mapping) - }) - .collect() - }; - for mapping_index in mapping_indices { - mappings = mappings.push(self.render_profile_mapping_row(profile_index, mapping_index)); - } - - let mut section = column![header].spacing(10); - if show_profile_controls { - section = section.push( - row![ - text_input("id", &profile.id) - .on_input(move |value| Message::RenderProfileTextChanged( - profile_index, - RenderProfileTextField::Id, - value - )) - .width(Length::FillPortion(1)), - text_input("name", &profile.name) - .on_input(move |value| Message::RenderProfileTextChanged( - profile_index, - RenderProfileTextField::Name, - value - )) - .width(Length::FillPortion(2)), - ] - .spacing(8), - ); - } - section = section.push(mappings); - if show_profile_controls { - section = section.push( - button("Add mapping").on_press(Message::RenderProfileMappingAdd(profile_index)), - ); - } - - container(section) - .padding(12) - .style(theme::Container::Box) - .into() - } - - fn render_profile_mapping_row( - &self, - profile_index: usize, - mapping_index: usize, - ) -> Element<'_, Message> { - let mapping = &self.draft.render_profiles.profiles[profile_index].mappings[mapping_index]; - let from = self.render_profile_color_control( - "From", - ColorPickerId::RenderProfileMappingFrom(profile_index, mapping_index), - &mapping.from, - profile_index, - mapping_index, - RenderProfileMappingSide::From, - ); - let to = self.render_profile_color_control( - "To", - ColorPickerId::RenderProfileMappingTo(profile_index, mapping_index), - &mapping.to, - profile_index, - mapping_index, - RenderProfileMappingSide::To, - ); - - column![ - row![ - from, - text("->").size(16), - to, - button("Remove").on_press(Message::RenderProfileMappingRemove( - profile_index, - mapping_index - )), - ] - .spacing(8) - .align_y(Alignment::Center), - ] - .spacing(6) - .into() - } - - fn render_profile_color_control<'a>( - &'a self, - label: &'static str, - id: ColorPickerId, - value: &'a str, - profile_index: usize, - mapping_index: usize, - side: RenderProfileMappingSide, - ) -> Element<'a, Message> { - let hex_value = self - .color_picker_hex - .get(&id) - .map(|value| value.as_str()) - .unwrap_or(value); - let rgb = render_profile_rgb(hex_value).unwrap_or([0.0, 0.0, 0.0]); - let preview = render_profile_rgb(hex_value) - .map(|rgb| iced::Color::from_rgb(rgb[0] as f32, rgb[1] as f32, rgb[2] as f32)); - let (hue, saturation, value_slider) = rgb_to_hsv(rgb); - let picker = if self.color_picker_open == Some(id) { - picker_panel(id, hue, saturation, value_slider, rgb, None) - } else { - column![].into() - }; - - column![ - row![ - text(label).size(12), - color_preview_badge(preview), - text_input("#RRGGBB", hex_value) - .on_input(move |value| Message::RenderProfileMappingColorChanged( - profile_index, - mapping_index, - side, - value - )) - .width(Length::Fixed(120.0)), - button(if self.color_picker_open == Some(id) { - "Hide" - } else { - "Pick" - }) - .on_press(Message::ColorPickerToggled(id)), - ] - .spacing(6) - .align_y(Alignment::Center), - picker, - ] - .spacing(6) - .width(Length::FillPortion(1)) - .into() - } -} - -fn render_profile_rgb(value: &str) -> Option<[f64; 3]> { - let color = parse_hex_rgb(value)?; - Some([ - f64::from(color.r) / 255.0, - f64::from(color.g) / 255.0, - f64::from(color.b) / 255.0, - ]) -} diff --git a/configurator/src/app/view/session.rs b/configurator/src/app/view/session.rs deleted file mode 100644 index bb8f77bd..00000000 --- a/configurator/src/app/view/session.rs +++ /dev/null @@ -1,382 +0,0 @@ -use iced::widget::{button, column, container, pick_list, row, rule, scrollable, text, text_input}; -use iced::{Element, Length}; - -use crate::app::scroll::CONTENT_SCROLL_ID; -use crate::app::session_catalog::{ - session_artifact_status_label, session_clear_cached_status_blocker, - session_clear_tool_state_cached_status_blocker, session_duplicate_cached_status_blocker, - session_move_cached_status_blocker, -}; -use crate::app::view::theme; -use crate::messages::Message; -use crate::models::{ - SessionCatalogItem, SessionCompressionOption, SessionStorageModeOption, TextField, ToggleField, -}; - -use super::super::search::{SearchArea, TabSearchSummary}; -use super::super::state::ConfiguratorApp; -use super::widgets::{ - labeled_control, labeled_input, labeled_input_with_feedback, toggle_row, validate_u64_min, - validate_u64_range, validate_usize_min, -}; - -impl ConfiguratorApp { - pub(super) fn session_tab(&self, search: Option<&TabSearchSummary>) -> Element<'_, Message> { - let show_all = search.is_none_or(TabSearchSummary::show_all); - let show_persistence = - search.is_none_or(|search| search.area_matches(SearchArea::SessionPersistence)); - let show_catalog = - search.is_none_or(|search| search.area_matches(SearchArea::SessionCatalog)); - let storage_pick = pick_list( - SessionStorageModeOption::list(), - Some(self.draft.session_storage_mode), - Message::SessionStorageModeChanged, - ); - let compression_pick = pick_list( - SessionCompressionOption::list(), - Some(self.draft.session_compression), - Message::SessionCompressionChanged, - ); - - let mut column = column![text("Session Persistence").size(20)].spacing(12); - - if show_persistence || show_all { - column = column - .push(toggle_row( - "Persist transparent mode drawings", - self.draft.session_persist_transparent, - self.defaults.session_persist_transparent, - ToggleField::SessionPersistTransparent, - )) - .push(toggle_row( - "Persist whiteboard mode drawings", - self.draft.session_persist_whiteboard, - self.defaults.session_persist_whiteboard, - ToggleField::SessionPersistWhiteboard, - )) - .push(toggle_row( - "Persist blackboard mode drawings", - self.draft.session_persist_blackboard, - self.defaults.session_persist_blackboard, - ToggleField::SessionPersistBlackboard, - )) - .push(toggle_row( - "Persist undo/redo history", - self.draft.session_persist_history, - self.defaults.session_persist_history, - ToggleField::SessionPersistHistory, - )) - .push(toggle_row( - "Restore tool state on startup", - self.draft.session_restore_tool_state, - self.defaults.session_restore_tool_state, - ToggleField::SessionRestoreToolState, - )) - .push(toggle_row( - "Enable autosave", - self.draft.session_autosave_enabled, - self.defaults.session_autosave_enabled, - ToggleField::SessionAutosaveEnabled, - )) - .push(toggle_row( - "Per-output persistence", - self.draft.session_per_output, - self.defaults.session_per_output, - ToggleField::SessionPerOutput, - )) - .push(labeled_control( - "Storage mode", - storage_pick.width(Length::Fill).into(), - self.defaults.session_storage_mode.label().to_string(), - self.draft.session_storage_mode != self.defaults.session_storage_mode, - )); - - if self.draft.session_storage_mode == SessionStorageModeOption::Custom { - column = column.push(labeled_input( - "Custom directory", - &self.draft.session_custom_directory, - &self.defaults.session_custom_directory, - TextField::SessionCustomDirectory, - )); - } - - column = column.push(labeled_control( - "Compression", - compression_pick.width(Length::Fill).into(), - self.defaults.session_compression.label().to_string(), - self.draft.session_compression != self.defaults.session_compression, - )); - - column = column.push(labeled_input_with_feedback( - "Autosave idle (ms)", - &self.draft.session_autosave_idle_ms, - &self.defaults.session_autosave_idle_ms, - TextField::SessionAutosaveIdleMs, - Some("Minimum: 1000 ms"), - validate_u64_min(&self.draft.session_autosave_idle_ms, 1000), - )); - column = column.push(labeled_input_with_feedback( - "Autosave interval (ms)", - &self.draft.session_autosave_interval_ms, - &self.defaults.session_autosave_interval_ms, - TextField::SessionAutosaveIntervalMs, - Some("Minimum: 1000 ms"), - validate_u64_min(&self.draft.session_autosave_interval_ms, 1000), - )); - column = column.push(labeled_input_with_feedback( - "Autosave failure backoff (ms)", - &self.draft.session_autosave_failure_backoff_ms, - &self.defaults.session_autosave_failure_backoff_ms, - TextField::SessionAutosaveFailureBackoffMs, - Some("Minimum: 1000 ms"), - validate_u64_min(&self.draft.session_autosave_failure_backoff_ms, 1000), - )); - column = column.push(labeled_input_with_feedback( - "Max shapes per frame", - &self.draft.session_max_shapes_per_frame, - &self.defaults.session_max_shapes_per_frame, - TextField::SessionMaxShapesPerFrame, - Some("Minimum: 1"), - validate_usize_min(&self.draft.session_max_shapes_per_frame, 1), - )); - column = column.push(labeled_input( - "Max persisted undo depth (blank = runtime limit)", - &self.draft.session_max_persisted_undo_depth, - &self.defaults.session_max_persisted_undo_depth, - TextField::SessionMaxPersistedUndoDepth, - )); - column = column.push(labeled_input_with_feedback( - "Max file size (MB)", - &self.draft.session_max_file_size_mb, - &self.defaults.session_max_file_size_mb, - TextField::SessionMaxFileSizeMb, - Some("Range: 1-1024 MB"), - validate_u64_range(&self.draft.session_max_file_size_mb, 1, 1024), - )); - column = column.push(labeled_input_with_feedback( - "Auto-compress threshold (KB)", - &self.draft.session_auto_compress_threshold_kb, - &self.defaults.session_auto_compress_threshold_kb, - TextField::SessionAutoCompressThresholdKb, - Some("Minimum: 1 KB"), - validate_u64_min(&self.draft.session_auto_compress_threshold_kb, 1), - )); - column = column.push(labeled_input( - "Backup retention count", - &self.draft.session_backup_retention, - &self.defaults.session_backup_retention, - TextField::SessionBackupRetention, - )); - } - - if show_catalog || show_all { - column = column - .push(rule::horizontal(1)) - .push(self.session_catalog_section(search)); - } - - scrollable(column).id(CONTENT_SCROLL_ID).into() - } - - fn session_catalog_section(&self, search: Option<&TabSearchSummary>) -> Element<'_, Message> { - let busy = self.session_catalog.busy || self.session_catalog.is_loading; - let mut refresh = button("Refresh").style(theme::Button::Secondary); - if !busy { - refresh = refresh.on_press(Message::SessionCatalogRefreshRequested); - } - - let mut section = column![ - row![text("Saved Sessions").size(20), refresh] - .spacing(12) - .align_y(iced::Alignment::Center), - text("Recent named session files recorded from overlay Open and Save As actions.") - .size(14), - text("Clear Tool State applies config defaults without deleting boards.").size(14), - text("Clear Saved Data removes saved session files.").size(14), - ] - .spacing(10); - - if let Some(blocker) = session_clear_cached_status_blocker(self.daemon_status.as_ref()) { - section = section.push( - text(blocker) - .size(12) - .style(theme::Text::Color(iced::Color::from_rgb(0.95, 0.8, 0.3))), - ); - } - - if self.session_catalog.is_loading { - return section.push(text("Loading sessions...").size(14)).into(); - } - - if self.session_catalog.items.is_empty() { - return section - .push(text("No named sessions in the catalog yet.").size(14)) - .into(); - } - - let visible_items = self - .session_catalog - .items - .iter() - .filter(|item| search.is_none_or(|search| search.session_item_visible(&item.id))); - - for item in visible_items { - section = section.push(self.session_catalog_item(item)); - } - - section.into() - } - - fn session_catalog_item<'a>(&'a self, item: &'a SessionCatalogItem) -> Element<'a, Message> { - let id = item.id.clone(); - let rename_value = self - .session_catalog - .rename_value(&item.id, &item.display_name); - let rename_changed = rename_value.trim() != item.display_name.trim(); - let rename_valid = !rename_value.trim().is_empty(); - let duplicate_value = self.session_catalog.duplicate_value(&item.id, &item.path); - let duplicate_valid = !duplicate_value.trim().is_empty(); - let move_value = self.session_catalog.move_value(&item.id, &item.path); - let move_valid = !move_value.trim().is_empty(); - let busy = self.session_catalog.busy || self.session_catalog.is_loading; - - let mut rename_button = button("Save Name").style(theme::Button::Secondary); - if !busy && rename_changed && rename_valid { - rename_button = - rename_button.on_press(Message::SessionCatalogRenameRequested(id.clone())); - } - - let duplicate_blocker = - session_duplicate_cached_status_blocker(self.daemon_status.as_ref()); - let mut duplicate_button = button("Duplicate").style(theme::Button::Secondary); - if !busy && duplicate_blocker.is_none() && duplicate_valid { - duplicate_button = - duplicate_button.on_press(Message::SessionCatalogDuplicateRequested(id.clone())); - } - - let move_blocker = session_move_cached_status_blocker(self.daemon_status.as_ref()); - let mut move_button = button("Move").style(theme::Button::Secondary); - if !busy && move_blocker.is_none() && move_valid { - move_button = move_button.on_press(Message::SessionCatalogMoveRequested(id.clone())); - } - - let mut reveal_button = button("Reveal File").style(theme::Button::Secondary); - if !busy { - reveal_button = - reveal_button.on_press(Message::SessionCatalogRevealRequested(id.clone())); - } - - let clear_tool_state_blocker = - session_clear_tool_state_cached_status_blocker(self.daemon_status.as_ref()); - let mut clear_tool_state_button = - button("Clear Tool State").style(theme::Button::Secondary); - if !busy && clear_tool_state_blocker.is_none() { - clear_tool_state_button = clear_tool_state_button - .on_press(Message::SessionCatalogClearToolStateRequested(id.clone())); - } - - let mut forget_button = button("Forget").style(theme::Button::Subtle); - if !busy { - forget_button = - forget_button.on_press(Message::SessionCatalogForgetRequested(id.clone())); - } - - let clear_blocker = session_clear_cached_status_blocker(self.daemon_status.as_ref()); - let clear_controls: Element<'a, Message> = - if self.session_catalog.pending_clear_id.as_deref() == Some(item.id.as_str()) { - row![ - button("Confirm Clear") - .style(theme::Button::Warning) - .on_press(Message::SessionCatalogClearConfirmed(id.clone())), - button("Cancel") - .style(theme::Button::Subtle) - .on_press(Message::SessionCatalogClearCanceled), - ] - .spacing(8) - .into() - } else { - let mut clear_button = button("Clear Saved Data").style(theme::Button::Warning); - if !busy && clear_blocker.is_none() { - clear_button = - clear_button.on_press(Message::SessionCatalogClearRequested(id.clone())); - } - clear_button.into() - }; - - let mut details = column![ - row![ - text(&item.display_name).size(16), - text(session_artifact_status_label(item)).size(12) - ] - .spacing(12) - .align_y(iced::Alignment::Center), - text(&item.path_label).size(12), - ] - .spacing(4); - if let Some(canonical) = item.canonical_path_label.as_deref() { - details = details.push(text(format!("Canonical: {canonical}")).size(12)); - } - details = details.push( - row![ - text(format!("Created: {}", item.created_label)).size(12), - text(format!("Opened: {}", item.last_opened_label)).size(12), - text(format!("Saved: {}", item.last_saved_label)).size(12), - ] - .spacing(12), - ); - - let rename_input = text_input("Display name", &rename_value) - .on_input(move |value| Message::SessionCatalogRenameInputChanged(id.clone(), value)) - .padding(8) - .width(Length::Fill); - - let duplicate_id = item.id.clone(); - let duplicate_input = text_input("Duplicate target path", &duplicate_value) - .on_input(move |value| { - Message::SessionCatalogDuplicateInputChanged(duplicate_id.clone(), value) - }) - .padding(8) - .width(Length::Fill); - - let move_id = item.id.clone(); - let move_input = text_input("Move target path", &move_value) - .on_input(move |value| Message::SessionCatalogMoveInputChanged(move_id.clone(), value)) - .padding(8) - .width(Length::Fill); - - let rename_controls = row![rename_input, rename_button] - .spacing(8) - .align_y(iced::Alignment::Center); - let duplicate_controls = row![duplicate_input, duplicate_button] - .spacing(8) - .align_y(iced::Alignment::Center); - let move_controls = row![move_input, move_button] - .spacing(8) - .align_y(iced::Alignment::Center); - - let actions = column![ - row![reveal_button, clear_tool_state_button] - .spacing(8) - .align_y(iced::Alignment::Center), - row![clear_controls, forget_button] - .spacing(8) - .align_y(iced::Alignment::Center), - ] - .spacing(8); - - container( - column![ - details, - rename_controls, - duplicate_controls, - move_controls, - actions - ] - .spacing(8), - ) - .padding(10) - .width(Length::Fill) - .style(theme::Container::Box) - .into() - } -} diff --git a/configurator/src/app/view/tablet.rs b/configurator/src/app/view/tablet.rs deleted file mode 100644 index 3f87d134..00000000 --- a/configurator/src/app/view/tablet.rs +++ /dev/null @@ -1,110 +0,0 @@ -use iced::widget::{column, pick_list, row, scrollable, text}; -use iced::{Element, Length}; - -use crate::app::scroll::CONTENT_SCROLL_ID; -use crate::messages::Message; -use crate::models::{ - PressureThicknessEditModeOption, PressureThicknessEntryModeOption, TextField, ToggleField, -}; - -use super::super::search::TabSearchSummary; -use super::super::state::ConfiguratorApp; -use super::widgets::{ - labeled_control, labeled_input_with_feedback, toggle_row, validate_f64_range, -}; - -impl ConfiguratorApp { - pub(super) fn tablet_tab(&self, _search: Option<&TabSearchSummary>) -> Element<'_, Message> { - let edit_mode = pick_list( - PressureThicknessEditModeOption::list(), - Some(self.draft.tablet_pressure_thickness_edit_mode), - Message::TabletPressureEditModeChanged, - ); - let entry_mode = pick_list( - PressureThicknessEntryModeOption::list(), - Some(self.draft.tablet_pressure_thickness_entry_mode), - Message::TabletPressureEntryModeChanged, - ); - scrollable( - column![ - text("Tablet / Stylus").size(20), - toggle_row( - "Enable tablet input", - self.draft.tablet_enabled, - self.defaults.tablet_enabled, - ToggleField::TabletEnabled, - ), - toggle_row( - "Enable pressure-to-thickness", - self.draft.tablet_pressure_enabled, - self.defaults.tablet_pressure_enabled, - ToggleField::TabletPressureEnabled, - ), - toggle_row( - "Auto-switch to eraser", - self.draft.tablet_auto_eraser_switch, - self.defaults.tablet_auto_eraser_switch, - ToggleField::TabletAutoEraserSwitch, - ), - row![ - labeled_input_with_feedback( - "Min thickness", - &self.draft.tablet_min_thickness, - &self.defaults.tablet_min_thickness, - TextField::TabletMinThickness, - Some("Range: 1-50"), - validate_f64_range(&self.draft.tablet_min_thickness, 1.0, 50.0), - ), - labeled_input_with_feedback( - "Max thickness", - &self.draft.tablet_max_thickness, - &self.defaults.tablet_max_thickness, - TextField::TabletMaxThickness, - Some("Range: 1-50"), - validate_f64_range(&self.draft.tablet_max_thickness, 1.0, 50.0), - ) - ] - .spacing(12), - labeled_input_with_feedback( - "Pressure variation threshold", - &self.draft.tablet_pressure_variation_threshold, - &self.defaults.tablet_pressure_variation_threshold, - TextField::TabletPressureVariationThreshold, - Some("Minimum: 0"), - None, - ), - labeled_input_with_feedback( - "Pressure thickness scale step", - &self.draft.tablet_pressure_thickness_scale_step, - &self.defaults.tablet_pressure_thickness_scale_step, - TextField::TabletPressureScaleStep, - Some("Range: 0-1"), - validate_f64_range(&self.draft.tablet_pressure_thickness_scale_step, 0.0, 1.0), - ), - labeled_control( - "Pressure thickness edit mode", - edit_mode.width(Length::Fill).into(), - self.defaults - .tablet_pressure_thickness_edit_mode - .label() - .to_string(), - self.draft.tablet_pressure_thickness_edit_mode - != self.defaults.tablet_pressure_thickness_edit_mode, - ), - labeled_control( - "Pressure thickness entry mode", - entry_mode.width(Length::Fill).into(), - self.defaults - .tablet_pressure_thickness_entry_mode - .label() - .to_string(), - self.draft.tablet_pressure_thickness_entry_mode - != self.defaults.tablet_pressure_thickness_entry_mode, - ), - ] - .spacing(12), - ) - .id(CONTENT_SCROLL_ID) - .into() - } -} diff --git a/configurator/src/app/view/theme.rs b/configurator/src/app/view/theme.rs deleted file mode 100644 index 6e5e1b92..00000000 --- a/configurator/src/app/view/theme.rs +++ /dev/null @@ -1,72 +0,0 @@ -#![allow(non_snake_case, non_upper_case_globals)] - -pub mod Button { - use iced::widget::button::{self, Status, Style}; - use iced::{Background, Border, Shadow}; - - pub use iced::widget::button::primary as Primary; - pub use iced::widget::button::secondary as Secondary; - pub use iced::widget::button::subtle as Subtle; - pub use iced::widget::button::warning as Warning; - - pub fn TabActive(theme: &iced::Theme, status: Status) -> Style { - let palette = theme.extended_palette(); - let background = match status { - Status::Active => palette.background.weakest.color, - Status::Hovered => palette.background.weak.color, - Status::Pressed => palette.background.strong.color, - Status::Disabled => palette.background.weakest.color.scale_alpha(0.5), - }; - let text_color = match status { - Status::Disabled => palette.primary.base.color.scale_alpha(0.5), - _ => palette.primary.strong.color, - }; - - Style { - background: Some(Background::Color(background)), - text_color, - border: Border { - color: palette.primary.base.color, - width: 1.0, - radius: 4.0.into(), - }, - shadow: Shadow::default(), - snap: true, - } - } - - pub fn TabInactive(theme: &iced::Theme, status: Status) -> Style { - let mut style = button::background(theme, status); - style.border.radius = 4.0.into(); - style - } -} - -pub mod Container { - use iced::{Background, Border, Shadow}; - - pub use iced::widget::container::rounded_box as Box; - pub use iced::widget::container::warning as Warning; - - pub fn ActionBar(theme: &iced::Theme) -> iced::widget::container::Style { - let palette = theme.extended_palette(); - - iced::widget::container::Style { - background: Some(Background::Color(palette.background.weakest.color)), - text_color: Some(palette.background.weakest.text), - border: Border { - color: palette.background.weak.color, - width: 1.0, - radius: 4.0.into(), - }, - shadow: Shadow::default(), - snap: true, - } - } -} - -pub mod Text { - pub fn Color(color: iced::Color) -> impl Fn(&iced::Theme) -> iced::widget::text::Style { - move |_theme| iced::widget::text::Style { color: Some(color) } - } -} diff --git a/configurator/src/app/view/ui/click_highlight.rs b/configurator/src/app/view/ui/click_highlight.rs deleted file mode 100644 index e7ecfd48..00000000 --- a/configurator/src/app/view/ui/click_highlight.rs +++ /dev/null @@ -1,110 +0,0 @@ -use iced::Element; -use iced::widget::{column, row, scrollable, text}; - -use crate::app::scroll::CONTENT_SCROLL_ID; -use crate::app::state::ConfiguratorApp; -use crate::messages::Message; -use crate::models::{ColorPickerId, QuadField, TextField, ToggleField}; - -use super::super::widgets::{ - ColorPickerUi, color_quad_picker, labeled_input_with_feedback, toggle_row, validate_f64_range, - validate_u64_range, -}; - -impl ConfiguratorApp { - pub(super) fn ui_click_highlight_tab(&self) -> Element<'_, Message> { - let column = column![ - text("Click Highlight").size(18), - toggle_row( - "Enable click highlight", - self.draft.click_highlight_enabled, - self.defaults.click_highlight_enabled, - ToggleField::UiClickHighlightEnabled, - ), - toggle_row( - "Show ring while highlight tool is active", - self.draft.click_highlight_show_on_highlight_tool, - self.defaults.click_highlight_show_on_highlight_tool, - ToggleField::UiClickHighlightShowOnHighlightTool, - ), - toggle_row( - "Link highlight color to current pen", - self.draft.click_highlight_use_pen_color, - self.defaults.click_highlight_use_pen_color, - ToggleField::UiClickHighlightUsePenColor, - ), - toggle_row( - "Force on when entering light mode", - self.draft.click_highlight_force_in_light_mode, - self.defaults.click_highlight_force_in_light_mode, - ToggleField::UiClickHighlightForceInLightMode, - ), - row![ - labeled_input_with_feedback( - "Radius", - &self.draft.click_highlight_radius, - &self.defaults.click_highlight_radius, - TextField::HighlightRadius, - Some("Range: 16-160"), - validate_f64_range(&self.draft.click_highlight_radius, 16.0, 160.0), - ), - labeled_input_with_feedback( - "Outline thickness", - &self.draft.click_highlight_outline_thickness, - &self.defaults.click_highlight_outline_thickness, - TextField::HighlightOutlineThickness, - Some("Range: 1-12"), - validate_f64_range(&self.draft.click_highlight_outline_thickness, 1.0, 12.0,), - ), - labeled_input_with_feedback( - "Duration (ms)", - &self.draft.click_highlight_duration_ms, - &self.defaults.click_highlight_duration_ms, - TextField::HighlightDurationMs, - Some("Range: 150-1500 ms"), - validate_u64_range(&self.draft.click_highlight_duration_ms, 150, 1500), - ) - ] - .spacing(12), - color_quad_picker( - "Fill RGBA (0-1)", - ColorPickerUi { - id: ColorPickerId::HighlightFill, - is_open: self.color_picker_open == Some(ColorPickerId::HighlightFill), - show_advanced: self - .color_picker_advanced - .contains(&ColorPickerId::HighlightFill), - hex_value: self - .color_picker_hex - .get(&ColorPickerId::HighlightFill) - .map(String::as_str) - .unwrap_or(""), - }, - &self.draft.click_highlight_fill_color, - &self.defaults.click_highlight_fill_color, - QuadField::HighlightFill, - ), - color_quad_picker( - "Outline RGBA (0-1)", - ColorPickerUi { - id: ColorPickerId::HighlightOutline, - is_open: self.color_picker_open == Some(ColorPickerId::HighlightOutline), - show_advanced: self - .color_picker_advanced - .contains(&ColorPickerId::HighlightOutline), - hex_value: self - .color_picker_hex - .get(&ColorPickerId::HighlightOutline) - .map(String::as_str) - .unwrap_or(""), - }, - &self.draft.click_highlight_outline_color, - &self.defaults.click_highlight_outline_color, - QuadField::HighlightOutline, - ) - ] - .spacing(12); - - scrollable(column).id(CONTENT_SCROLL_ID).into() - } -} diff --git a/configurator/src/app/view/ui/help_overlay.rs b/configurator/src/app/view/ui/help_overlay.rs deleted file mode 100644 index c138458b..00000000 --- a/configurator/src/app/view/ui/help_overlay.rs +++ /dev/null @@ -1,111 +0,0 @@ -use iced::Element; -use iced::widget::{column, row, scrollable, text}; - -use crate::app::scroll::CONTENT_SCROLL_ID; -use crate::app::state::ConfiguratorApp; -use crate::messages::Message; -use crate::models::{ColorPickerId, QuadField, TextField, ToggleField}; - -use super::super::widgets::{ColorPickerUi, color_quad_picker, labeled_input, toggle_row}; - -impl ConfiguratorApp { - pub(super) fn ui_help_overlay_tab(&self) -> Element<'_, Message> { - let column = column![ - text("Help Overlay Style").size(18), - toggle_row( - "Filter sections by enabled features", - self.draft.help_context_filter, - self.defaults.help_context_filter, - ToggleField::UiHelpOverlayContextFilter, - ), - color_quad_picker( - "Background RGBA (0-1)", - ColorPickerUi { - id: ColorPickerId::HelpBg, - is_open: self.color_picker_open == Some(ColorPickerId::HelpBg), - show_advanced: self.color_picker_advanced.contains(&ColorPickerId::HelpBg), - hex_value: self - .color_picker_hex - .get(&ColorPickerId::HelpBg) - .map(String::as_str) - .unwrap_or(""), - }, - &self.draft.help_bg_color, - &self.defaults.help_bg_color, - QuadField::HelpBg, - ), - color_quad_picker( - "Border RGBA (0-1)", - ColorPickerUi { - id: ColorPickerId::HelpBorder, - is_open: self.color_picker_open == Some(ColorPickerId::HelpBorder), - show_advanced: self - .color_picker_advanced - .contains(&ColorPickerId::HelpBorder), - hex_value: self - .color_picker_hex - .get(&ColorPickerId::HelpBorder) - .map(String::as_str) - .unwrap_or(""), - }, - &self.draft.help_border_color, - &self.defaults.help_border_color, - QuadField::HelpBorder, - ), - color_quad_picker( - "Text RGBA (0-1)", - ColorPickerUi { - id: ColorPickerId::HelpText, - is_open: self.color_picker_open == Some(ColorPickerId::HelpText), - show_advanced: self - .color_picker_advanced - .contains(&ColorPickerId::HelpText), - hex_value: self - .color_picker_hex - .get(&ColorPickerId::HelpText) - .map(String::as_str) - .unwrap_or(""), - }, - &self.draft.help_text_color, - &self.defaults.help_text_color, - QuadField::HelpText, - ), - labeled_input( - "Font family", - &self.draft.help_font_family, - &self.defaults.help_font_family, - TextField::HelpFontFamily, - ), - row![ - labeled_input( - "Font size", - &self.draft.help_font_size, - &self.defaults.help_font_size, - TextField::HelpFontSize, - ), - labeled_input( - "Line height", - &self.draft.help_line_height, - &self.defaults.help_line_height, - TextField::HelpLineHeight, - ), - labeled_input( - "Padding", - &self.draft.help_padding, - &self.defaults.help_padding, - TextField::HelpPadding, - ), - labeled_input( - "Border width", - &self.draft.help_border_width, - &self.defaults.help_border_width, - TextField::HelpBorderWidth, - ) - ] - .spacing(12), - ] - .spacing(12); - - scrollable(column).id(CONTENT_SCROLL_ID).into() - } -} diff --git a/configurator/src/app/view/ui/input_hud.rs b/configurator/src/app/view/ui/input_hud.rs deleted file mode 100644 index 1588b298..00000000 --- a/configurator/src/app/view/ui/input_hud.rs +++ /dev/null @@ -1,119 +0,0 @@ -use iced::widget::{column, pick_list, row, scrollable, text}; -use iced::{Element, Length}; - -use crate::app::scroll::CONTENT_SCROLL_ID; -use crate::app::state::ConfiguratorApp; -use crate::messages::Message; -use crate::models::{InputHudModeOption, InputHudPositionOption, TextField, ToggleField}; - -use super::super::widgets::{ - labeled_control, labeled_input_with_feedback, toggle_row, validate_f64_range, - validate_u64_range, validate_usize_range, -}; - -impl ConfiguratorApp { - pub(super) fn ui_input_hud_tab(&self) -> Element<'_, Message> { - let mode_pick = pick_list( - InputHudModeOption::list(), - Some(self.draft.input_hud_mode), - Message::InputHudModeChanged, - ); - let position_pick = pick_list( - InputHudPositionOption::list(), - Some(self.draft.input_hud_position), - Message::InputHudPositionChanged, - ); - - let column = column![ - text("Input HUD").size(18), - text("Show a live row of keystroke and click chips for demos and screencasts.") - .size(12) - .color(iced::Color::from_rgb(0.6, 0.6, 0.6)), - toggle_row( - "Enable input HUD", - self.draft.input_hud_enabled, - self.defaults.input_hud_enabled, - ToggleField::UiInputHudEnabled, - ), - labeled_control( - "Input source", - mode_pick.width(Length::Fill).into(), - self.defaults.input_hud_mode.label().to_string(), - self.draft.input_hud_mode != self.defaults.input_hud_mode, - ), - text( - "\"Overlay only\" shows what Wayscriber itself receives. \"System-wide\" also \ - shows input that goes to other apps; it needs a build with the input-monitor \ - feature and read access to /dev/input (usually `input` group membership), and \ - it sees every keystroke on the seat - including passwords typed elsewhere." - ) - .size(12) - .color(iced::Color::from_rgb(0.6, 0.6, 0.6)), - labeled_control( - "Screen position", - position_pick.width(Length::Fill).into(), - self.defaults.input_hud_position.label().to_string(), - self.draft.input_hud_position != self.defaults.input_hud_position, - ), - toggle_row( - "Show mouse buttons and scroll", - self.draft.input_hud_show_mouse, - self.defaults.input_hud_show_mouse, - ToggleField::UiInputHudShowMouse, - ), - toggle_row( - "Show bare modifier taps", - self.draft.input_hud_show_bare_modifiers, - self.defaults.input_hud_show_bare_modifiers, - ToggleField::UiInputHudShowBareModifiers, - ), - toggle_row( - "Combine repeats into a counter", - self.draft.input_hud_combine_repeats, - self.defaults.input_hud_combine_repeats, - ToggleField::UiInputHudCombineRepeats, - ), - row![ - labeled_input_with_feedback( - "Hold (ms)", - &self.draft.input_hud_display_ms, - &self.defaults.input_hud_display_ms, - TextField::InputHudDisplayMs, - Some("Range: 200-30000 ms"), - validate_u64_range(&self.draft.input_hud_display_ms, 200, 30_000), - ), - labeled_input_with_feedback( - "Fade (ms)", - &self.draft.input_hud_fade_ms, - &self.defaults.input_hud_fade_ms, - TextField::InputHudFadeMs, - Some("Range: 0-5000 ms"), - validate_u64_range(&self.draft.input_hud_fade_ms, 0, 5_000), - ), - ] - .spacing(12), - row![ - labeled_input_with_feedback( - "Max chips", - &self.draft.input_hud_max_entries, - &self.defaults.input_hud_max_entries, - TextField::InputHudMaxEntries, - Some("Range: 1-16"), - validate_usize_range(&self.draft.input_hud_max_entries, 1, 16), - ), - labeled_input_with_feedback( - "Font size", - &self.draft.input_hud_font_size, - &self.defaults.input_hud_font_size, - TextField::InputHudFontSize, - Some("Range: 6-72"), - validate_f64_range(&self.draft.input_hud_font_size, 6.0, 72.0), - ), - ] - .spacing(12), - ] - .spacing(12); - - scrollable(column).id(CONTENT_SCROLL_ID).into() - } -} diff --git a/configurator/src/app/view/ui/mod.rs b/configurator/src/app/view/ui/mod.rs deleted file mode 100644 index 87cbd403..00000000 --- a/configurator/src/app/view/ui/mod.rs +++ /dev/null @@ -1,151 +0,0 @@ -mod click_highlight; -mod help_overlay; -mod input_hud; -mod presenter_mode; -mod status_bar; -mod toolbar; - -use crate::app::view::theme; -use iced::widget::{Row, button, column, pick_list, text}; -use iced::{Element, Length}; - -use crate::app::state::ConfiguratorApp; -use crate::messages::Message; -use crate::models::{ReducedMotionOption, TextField, ToggleField, UiTabId, UiThemeOption}; - -use super::super::search::{SearchArea, TabSearchSummary}; -use super::widgets::{labeled_control, labeled_input, toggle_row}; - -impl ConfiguratorApp { - pub(super) fn ui_tab(&self, search: Option<&TabSearchSummary>) -> Element<'_, Message> { - let show_general = search.is_none_or(|search| search.area_matches(SearchArea::UiGeneral)); - let tabs = visible_ui_tabs(search); - let active_tab = active_ui_tab(search, self.active_ui_tab); - let tab_bar = tabs.iter().fold( - Row::new().spacing(8).align_y(iced::Alignment::Center), - |row, tab| { - let label = tab.title(); - let button = button(label) - .padding([4, 10]) - .style(if Some(*tab) == active_tab { - theme::Button::Primary - } else { - theme::Button::Secondary - }) - .on_press(Message::UiTabSelected(*tab)); - row.push(button) - }, - ); - - let content = match active_tab { - Some(UiTabId::Toolbar) => Some(self.ui_toolbar_tab()), - Some(UiTabId::ToolbarVisibility) => Some(self.ui_toolbar_visibility_tab()), - Some(UiTabId::StatusBar) => Some(self.ui_status_bar_tab()), - Some(UiTabId::HelpOverlay) => Some(self.ui_help_overlay_tab()), - Some(UiTabId::ClickHighlight) => Some(self.ui_click_highlight_tab()), - Some(UiTabId::InputHud) => Some(self.ui_input_hud_tab()), - Some(UiTabId::PresenterMode) => Some(self.ui_presenter_mode_tab()), - None => None, - }; - - let ui_theme = pick_list( - UiThemeOption::list(), - Some(self.draft.ui_theme), - Message::UiThemeChanged, - ); - let reduced_motion = pick_list( - ReducedMotionOption::list(), - Some(self.draft.ui_reduced_motion), - Message::UiReducedMotionChanged, - ); - - let general = column![ - text("General UI").size(18), - labeled_control( - "Theme", - ui_theme.width(Length::Fill).into(), - self.defaults.ui_theme.label().to_string(), - self.draft.ui_theme != self.defaults.ui_theme, - ), - text("\"Auto\" currently uses the dark theme; \"Light\" takes effect as overlay surfaces adopt the runtime theme.") - .size(12) - .style(theme::Text::Color(iced::Color::from_rgb(0.6, 0.6, 0.6))), - labeled_control( - "Reduced motion", - reduced_motion.width(Length::Fill).into(), - self.defaults.ui_reduced_motion.label().to_string(), - self.draft.ui_reduced_motion != self.defaults.ui_reduced_motion, - ), - text("\"On\" disables UI animations. \"Auto\" follows the system preference in a future release and keeps full motion for now.") - .size(12) - .style(theme::Text::Color(iced::Color::from_rgb(0.6, 0.6, 0.6))), - labeled_input( - "Preferred output (GNOME fallback)", - &self.draft.ui_preferred_output, - &self.defaults.ui_preferred_output, - TextField::UiPreferredOutput, - ), - text("Used for the GNOME xdg-shell fallback overlay.") - .size(12) - .style(theme::Text::Color(iced::Color::from_rgb(0.6, 0.6, 0.6))), - toggle_row( - "Use fullscreen xdg fallback", - self.draft.ui_xdg_fullscreen, - self.defaults.ui_xdg_fullscreen, - ToggleField::UiXdgFullscreen, - ), - toggle_row( - "Keep open on xdg focus loss", - self.draft.ui_xdg_keep_on_focus_loss, - self.defaults.ui_xdg_keep_on_focus_loss, - ToggleField::UiXdgKeepOnFocusLoss, - ), - toggle_row( - "Enable context menu", - self.draft.ui_context_menu_enabled, - self.defaults.ui_context_menu_enabled, - ToggleField::UiContextMenuEnabled, - ), - toggle_row( - "Show capabilities warning toast", - self.draft.ui_show_capabilities_warning, - self.defaults.ui_show_capabilities_warning, - ToggleField::UiShowCapabilitiesWarning, - ), - labeled_input( - "Command palette toast (ms)", - &self.draft.ui_command_palette_toast_duration_ms, - &self.defaults.ui_command_palette_toast_duration_ms, - TextField::UiCommandPaletteToastDurationMs, - ) - ] - .spacing(12); - - let mut page = column![text("UI Settings").size(20)].spacing(12); - if show_general { - page = page.push(general); - } - if !tabs.is_empty() { - page = page.push(tab_bar); - } - if let Some(content) = content { - page = page.push(content); - } - page.into() - } -} - -fn visible_ui_tabs(search: Option<&TabSearchSummary>) -> Vec { - match search { - Some(summary) if !summary.show_all() => summary.ui_tabs().to_vec(), - _ => UiTabId::ALL.to_vec(), - } -} - -fn active_ui_tab(search: Option<&TabSearchSummary>, preferred: UiTabId) -> Option { - match search { - Some(summary) if summary.show_all() || summary.ui_tab_visible(preferred) => Some(preferred), - Some(summary) => summary.ui_tabs().first().copied(), - None => Some(preferred), - } -} diff --git a/configurator/src/app/view/ui/presenter_mode.rs b/configurator/src/app/view/ui/presenter_mode.rs deleted file mode 100644 index 1722ee5a..00000000 --- a/configurator/src/app/view/ui/presenter_mode.rs +++ /dev/null @@ -1,88 +0,0 @@ -use iced::widget::{column, pick_list, scrollable, text}; -use iced::{Element, Length}; - -use crate::app::scroll::CONTENT_SCROLL_ID; -use crate::app::state::ConfiguratorApp; -use crate::messages::Message; -use crate::models::{PresenterToolBehaviorOption, PresenterToolbarModeOption, ToggleField}; - -use super::super::widgets::{labeled_control, toggle_row}; - -impl ConfiguratorApp { - pub(super) fn ui_presenter_mode_tab(&self) -> Element<'_, Message> { - let tool_behavior_pick = pick_list( - PresenterToolBehaviorOption::list(), - Some(self.draft.presenter_tool_behavior), - Message::PresenterToolBehaviorChanged, - ); - let toolbar_mode_pick = pick_list( - PresenterToolbarModeOption::list(), - Some(self.draft.presenter_toolbar_mode), - Message::PresenterToolbarModeChanged, - ); - - let column = column![ - text("Presenter Mode").size(18), - text("Customize what presenter mode changes when toggled.") - .size(12) - .color(iced::Color::from_rgb(0.6, 0.6, 0.6)), - toggle_row( - "Hide status bar", - self.draft.presenter_hide_status_bar, - self.defaults.presenter_hide_status_bar, - ToggleField::PresenterHideStatusBar, - ), - toggle_row( - "Hide toolbars", - self.draft.presenter_hide_toolbars, - self.defaults.presenter_hide_toolbars, - ToggleField::PresenterHideToolbars, - ), - labeled_control( - "Top toolbar while presenting", - toolbar_mode_pick.width(Length::Fill).into(), - self.defaults.presenter_toolbar_mode.label().to_string(), - self.draft.presenter_toolbar_mode != self.defaults.presenter_toolbar_mode, - ), - toggle_row( - "Hide tool preview", - self.draft.presenter_hide_tool_preview, - self.defaults.presenter_hide_tool_preview, - ToggleField::PresenterHideToolPreview, - ), - toggle_row( - "Close help overlay on entry", - self.draft.presenter_close_help_overlay, - self.defaults.presenter_close_help_overlay, - ToggleField::PresenterCloseHelpOverlay, - ), - toggle_row( - "Force click highlights on", - self.draft.presenter_enable_click_highlight, - self.defaults.presenter_enable_click_highlight, - ToggleField::PresenterEnableClickHighlight, - ), - toggle_row( - "Force input HUD on", - self.draft.presenter_enable_input_hud, - self.defaults.presenter_enable_input_hud, - ToggleField::PresenterEnableInputHud, - ), - labeled_control( - "Tool behavior", - tool_behavior_pick.width(Length::Fill).into(), - self.defaults.presenter_tool_behavior.label().to_string(), - self.draft.presenter_tool_behavior != self.defaults.presenter_tool_behavior, - ), - toggle_row( - "Show enter/exit toast", - self.draft.presenter_show_toast, - self.defaults.presenter_show_toast, - ToggleField::PresenterShowToast, - ), - ] - .spacing(12); - - scrollable(column).id(CONTENT_SCROLL_ID).into() - } -} diff --git a/configurator/src/app/view/ui/status_bar.rs b/configurator/src/app/view/ui/status_bar.rs deleted file mode 100644 index 085b4450..00000000 --- a/configurator/src/app/view/ui/status_bar.rs +++ /dev/null @@ -1,190 +0,0 @@ -use iced::widget::{column, pick_list, row, scrollable, text}; -use iced::{Element, Length}; - -use crate::app::scroll::CONTENT_SCROLL_ID; -use crate::app::state::ConfiguratorApp; -use crate::messages::Message; -use crate::models::{ColorPickerId, QuadField, StatusPositionOption, TextField, ToggleField}; - -use super::super::widgets::{ - ColorPickerUi, color_quad_picker, labeled_control, labeled_input, toggle_row, -}; - -impl ConfiguratorApp { - pub(super) fn ui_status_bar_tab(&self) -> Element<'_, Message> { - let status_position = pick_list( - StatusPositionOption::list(), - Some(self.draft.ui_status_position), - Message::StatusPositionChanged, - ); - - let column = column![ - text("Status Bar").size(18), - toggle_row( - "Show status bar", - self.draft.ui_show_status_bar, - self.defaults.ui_show_status_bar, - ToggleField::UiShowStatusBar, - ), - toggle_row( - "Clickable status bar segments", - self.draft.ui_status_bar_interactive, - self.defaults.ui_status_bar_interactive, - ToggleField::UiStatusBarInteractive, - ), - text("Contents").size(18), - toggle_row( - "Show active output", - self.draft.ui_active_output_badge, - self.defaults.ui_active_output_badge, - ToggleField::UiActiveOutputBadge, - ), - toggle_row( - "Show selection dimensions", - self.draft.ui_show_status_selection_info, - self.defaults.ui_show_status_selection_info, - ToggleField::UiShowStatusSelectionInfo, - ), - toggle_row( - "Show board label", - self.draft.ui_show_status_board_badge, - self.defaults.ui_show_status_board_badge, - ToggleField::UiShowStatusBoardBadge, - ), - toggle_row( - "Show page counter", - self.draft.ui_show_status_page_badge, - self.defaults.ui_show_status_page_badge, - ToggleField::UiShowStatusPageBadge, - ), - toggle_row( - "Show current color", - self.draft.ui_show_status_color, - self.defaults.ui_show_status_color, - ToggleField::UiShowStatusColor, - ), - toggle_row( - "Show active tool", - self.draft.ui_show_status_tool, - self.defaults.ui_show_status_tool, - ToggleField::UiShowStatusTool, - ), - toggle_row( - "Show tool size", - self.draft.ui_show_status_size, - self.defaults.ui_show_status_size, - ToggleField::UiShowStatusSize, - ), - toggle_row( - "Show context indicators", - self.draft.ui_show_status_context_indicators, - self.defaults.ui_show_status_context_indicators, - ToggleField::UiShowStatusContextIndicators, - ), - toggle_row( - "Show toolbar hint while toolbars are hidden", - self.draft.ui_show_toolbar_hint, - self.defaults.ui_show_toolbar_hint, - ToggleField::UiShowToolbarHint, - ), - toggle_row( - "Show Help shortcut", - self.draft.ui_show_status_help, - self.defaults.ui_show_status_help, - ToggleField::UiShowStatusHelp, - ), - toggle_row( - "Show About and version", - self.draft.ui_show_status_about, - self.defaults.ui_show_status_about, - ToggleField::UiShowStatusAbout, - ), - text("Additional Badges").size(18), - toggle_row( - "Show board/page badge", - self.draft.ui_show_floating_badge, - self.defaults.ui_show_floating_badge, - ToggleField::UiShowFloatingBadge, - ), - toggle_row( - "Also show badge with status bar", - self.draft.ui_show_page_badge_with_status_bar, - self.defaults.ui_show_page_badge_with_status_bar, - ToggleField::UiShowPageBadgeWithStatusBar, - ), - toggle_row( - "Show frozen badge", - self.draft.ui_show_frozen_badge, - self.defaults.ui_show_frozen_badge, - ToggleField::UiShowFrozenBadge, - ), - labeled_control( - "Status bar position", - status_position.width(Length::Fill).into(), - self.defaults.ui_status_position.label().to_string(), - self.draft.ui_status_position != self.defaults.ui_status_position, - ), - text("Status Bar Style").size(18), - color_quad_picker( - "Background RGBA (0-1)", - ColorPickerUi { - id: ColorPickerId::StatusBarBg, - is_open: self.color_picker_open == Some(ColorPickerId::StatusBarBg), - show_advanced: self - .color_picker_advanced - .contains(&ColorPickerId::StatusBarBg), - hex_value: self - .color_picker_hex - .get(&ColorPickerId::StatusBarBg) - .map(String::as_str) - .unwrap_or(""), - }, - &self.draft.status_bar_bg_color, - &self.defaults.status_bar_bg_color, - QuadField::StatusBarBg, - ), - color_quad_picker( - "Text RGBA (0-1)", - ColorPickerUi { - id: ColorPickerId::StatusBarText, - is_open: self.color_picker_open == Some(ColorPickerId::StatusBarText), - show_advanced: self - .color_picker_advanced - .contains(&ColorPickerId::StatusBarText), - hex_value: self - .color_picker_hex - .get(&ColorPickerId::StatusBarText) - .map(String::as_str) - .unwrap_or(""), - }, - &self.draft.status_bar_text_color, - &self.defaults.status_bar_text_color, - QuadField::StatusBarText, - ), - row![ - labeled_input( - "Font size", - &self.draft.status_font_size, - &self.defaults.status_font_size, - TextField::StatusFontSize, - ), - labeled_input( - "Padding", - &self.draft.status_padding, - &self.defaults.status_padding, - TextField::StatusPadding, - ), - labeled_input( - "Dot radius", - &self.draft.status_dot_radius, - &self.defaults.status_dot_radius, - TextField::StatusDotRadius, - ) - ] - .spacing(12), - ] - .spacing(12); - - scrollable(column).id(CONTENT_SCROLL_ID).into() - } -} diff --git a/configurator/src/app/view/ui/toolbar.rs b/configurator/src/app/view/ui/toolbar.rs deleted file mode 100644 index ec8e57bb..00000000 --- a/configurator/src/app/view/ui/toolbar.rs +++ /dev/null @@ -1,471 +0,0 @@ -use std::collections::BTreeSet; - -use iced::widget::{button, checkbox, column, pick_list, row, scrollable, text}; -use iced::{Element, Length}; -use wayscriber::config::{ - ResolvedToolbarItems, ToolbarItemCategory, ToolbarItemDefinition, ToolbarItemOrderGroup, - ToolbarItemSurface, ToolbarItemsConfig, toolbar_item_definitions, toolbar_item_order_group, -}; - -use crate::app::scroll::CONTENT_SCROLL_ID; -use crate::app::state::ConfiguratorApp; -use crate::messages::Message; -use crate::models::{ - TextField, ToggleField, ToolbarLayoutModeOption, ToolbarOverrideField, - ToolbarRebindModifierOption, ToolbarSideLayoutOption, ZoomChipDisplayOption, -}; - -use super::super::widgets::{labeled_control, labeled_input, override_row, toggle_row}; - -impl ConfiguratorApp { - pub(super) fn ui_toolbar_tab(&self) -> Element<'_, Message> { - let toolbar_layout = pick_list( - ToolbarLayoutModeOption::list(), - Some(self.draft.ui_toolbar_layout_mode), - Message::ToolbarLayoutModeChanged, - ); - let override_mode_pick = pick_list( - ToolbarLayoutModeOption::list(), - Some(self.override_mode), - Message::ToolbarOverrideModeChanged, - ); - let side_layout = pick_list( - ToolbarSideLayoutOption::list(), - Some(self.draft.ui_toolbar_side_layout), - Message::ToolbarSideLayoutChanged, - ); - let zoom_chip_display = pick_list( - ZoomChipDisplayOption::list(), - Some(self.draft.ui_toolbar_zoom_chip_display), - Message::ToolbarZoomChipDisplayChanged, - ); - let rebind_modifier = pick_list( - ToolbarRebindModifierOption::ALL, - Some(self.draft.ui_toolbar_rebind_modifier), - Message::ToolbarRebindModifierChanged, - ); - let overrides = self - .draft - .ui_toolbar_mode_overrides - .for_mode(self.override_mode); - - let column = column![ - text("Toolbar").size(18), - text("These settings are configured defaults. Toolbar pins, position, display form, item visibility/order, pane state, and board pins changed in the overlay are saved separately as runtime preferences.").size(12), - labeled_control( - "Layout mode", - toolbar_layout.width(Length::Fill).into(), - self.defaults.ui_toolbar_layout_mode.label().to_string(), - self.draft.ui_toolbar_layout_mode != self.defaults.ui_toolbar_layout_mode, - ), - labeled_control( - "Side layout", - side_layout.width(Length::Fill).into(), - self.defaults.ui_toolbar_side_layout.label().to_string(), - self.draft.ui_toolbar_side_layout != self.defaults.ui_toolbar_side_layout, - ), - labeled_control( - "Zoom chip", - zoom_chip_display.width(Length::Fill).into(), - self.defaults.ui_toolbar_zoom_chip_display.label().to_string(), - self.draft.ui_toolbar_zoom_chip_display - != self.defaults.ui_toolbar_zoom_chip_display, - ), - toggle_row( - "Show zoom chip", - self.draft.ui_toolbar_show_zoom_chip, - self.defaults.ui_toolbar_show_zoom_chip, - ToggleField::UiToolbarShowZoomChip, - ), - text("Pill (the default) retires the side palette: drawing properties live in the top strip's style pill, canvas management in the status HUD and board picker, and Session/Settings in popovers on the top strip's overflow menu. Panel is the legacy escape hatch restoring the classic side palette; it is deprecated and planned for removal one release after the pill default.").size(12), - labeled_control( - "Shortcut edit click", - rebind_modifier.width(Length::Fill).into(), - self.defaults.ui_toolbar_rebind_modifier.label().to_string(), - self.draft.ui_toolbar_rebind_modifier != self.defaults.ui_toolbar_rebind_modifier, - ), - toggle_row( - "Configured default: pin top toolbar", - self.draft.ui_toolbar_top_pinned, - self.defaults.ui_toolbar_top_pinned, - ToggleField::UiToolbarTopPinned, - ), - toggle_row( - "Configured default: pin side toolbar", - self.draft.ui_toolbar_side_pinned, - self.defaults.ui_toolbar_side_pinned, - ToggleField::UiToolbarSidePinned, - ), - toggle_row( - "Use icon-only buttons", - self.draft.ui_toolbar_use_icons, - self.defaults.ui_toolbar_use_icons, - ToggleField::UiToolbarUseIcons, - ), - toggle_row( - "Show extended colors", - self.draft.ui_toolbar_show_more_colors, - self.defaults.ui_toolbar_show_more_colors, - ToggleField::UiToolbarShowMoreColors, - ), - toggle_row( - "Show presets", - self.draft.ui_toolbar_show_presets, - self.defaults.ui_toolbar_show_presets, - ToggleField::UiToolbarShowPresets, - ), - toggle_row( - "Show actions", - self.draft.ui_toolbar_show_actions_section, - self.defaults.ui_toolbar_show_actions_section, - ToggleField::UiToolbarShowActionsSection, - ), - toggle_row( - "Show zoom actions", - self.draft.ui_toolbar_show_zoom_actions, - self.defaults.ui_toolbar_show_zoom_actions, - ToggleField::UiToolbarShowZoomActions, - ), - toggle_row( - "Show advanced actions", - self.draft.ui_toolbar_show_actions_advanced, - self.defaults.ui_toolbar_show_actions_advanced, - ToggleField::UiToolbarShowActionsAdvanced, - ), - toggle_row( - "Show pages section", - self.draft.ui_toolbar_show_pages_section, - self.defaults.ui_toolbar_show_pages_section, - ToggleField::UiToolbarShowPagesSection, - ), - toggle_row( - "Show boards section", - self.draft.ui_toolbar_show_boards_section, - self.defaults.ui_toolbar_show_boards_section, - ToggleField::UiToolbarShowBoardsSection, - ), - toggle_row( - "Show multi-step undo/redo", - self.draft.ui_toolbar_show_step_section, - self.defaults.ui_toolbar_show_step_section, - ToggleField::UiToolbarShowStepSection, - ), - toggle_row( - "Always show text controls", - self.draft.ui_toolbar_show_text_controls, - self.defaults.ui_toolbar_show_text_controls, - ToggleField::UiToolbarShowTextControls, - ), - toggle_row( - "Show delay sliders", - self.draft.ui_toolbar_show_delay_sliders, - self.defaults.ui_toolbar_show_delay_sliders, - ToggleField::UiToolbarShowDelaySliders, - ), - toggle_row( - "Show marker opacity controls", - self.draft.ui_toolbar_show_marker_opacity_section, - self.defaults.ui_toolbar_show_marker_opacity_section, - ToggleField::UiToolbarShowMarkerOpacitySection, - ), - toggle_row( - "Show tool preview bubble", - self.draft.ui_toolbar_show_tool_preview, - self.defaults.ui_toolbar_show_tool_preview, - ToggleField::UiToolbarShowToolPreview, - ), - toggle_row( - "Show preset action toasts", - self.draft.ui_toolbar_show_preset_toasts, - self.defaults.ui_toolbar_show_preset_toasts, - ToggleField::UiToolbarPresetToasts, - ), - toggle_row( - "Force inline toolbars", - self.draft.ui_toolbar_force_inline, - self.defaults.ui_toolbar_force_inline, - ToggleField::UiToolbarForceInline, - ), - text("Mode overrides").size(16), - row![text("Edit mode:"), override_mode_pick] - .spacing(12) - .align_y(iced::Alignment::Center), - text("Default keeps the mode preset.").size(12), - override_row(ToolbarOverrideField::ShowPresets, overrides.show_presets), - override_row( - ToolbarOverrideField::ShowActionsSection, - overrides.show_actions_section, - ), - override_row( - ToolbarOverrideField::ShowZoomActions, - overrides.show_zoom_actions - ), - override_row( - ToolbarOverrideField::ShowActionsAdvanced, - overrides.show_actions_advanced, - ), - override_row( - ToolbarOverrideField::ShowPagesSection, - overrides.show_pages_section, - ), - override_row( - ToolbarOverrideField::ShowBoardsSection, - overrides.show_boards_section, - ), - override_row( - ToolbarOverrideField::ShowStepSection, - overrides.show_step_section - ), - override_row( - ToolbarOverrideField::ShowTextControls, - overrides.show_text_controls - ), - text("Placement offsets").size(16), - text("Configured defaults. Dragging a toolbar in the overlay saves that position as a runtime preference; editing a value here takes over from the saved drag.").size(12), - row![ - labeled_input( - "Top offset X (px)", - &self.draft.ui_toolbar_top_offset, - &self.defaults.ui_toolbar_top_offset, - TextField::ToolbarTopOffset, - ), - labeled_input( - "Top offset Y (px)", - &self.draft.ui_toolbar_top_offset_y, - &self.defaults.ui_toolbar_top_offset_y, - TextField::ToolbarTopOffsetY, - ) - ] - .spacing(12), - row![ - labeled_input( - "Side offset Y (px)", - &self.draft.ui_toolbar_side_offset, - &self.defaults.ui_toolbar_side_offset, - TextField::ToolbarSideOffset, - ), - labeled_input( - "Side offset X (px)", - &self.draft.ui_toolbar_side_offset_x, - &self.defaults.ui_toolbar_side_offset_x, - TextField::ToolbarSideOffsetX, - ) - ] - .spacing(12), - ] - .spacing(12); - - scrollable(column).id(CONTENT_SCROLL_ID).into() - } - - pub(super) fn ui_toolbar_visibility_tab(&self) -> Element<'_, Message> { - let column = column![ - text("Toolbar Visibility").size(18), - text("These are configured visibility defaults. Overlay customizations are stored separately as runtime preferences. Checked items are shown; section toggles and mode overrides can still hide them.").size(12), - toolbar_item_visibility_section( - &self.draft.ui_toolbar_items, - &self.defaults.ui_toolbar_items, - ), - ] - .spacing(12); - - scrollable(column).id(CONTENT_SCROLL_ID).into() - } -} - -fn toolbar_item_visibility_section<'a>( - items: &ToolbarItemsConfig, - defaults: &ToolbarItemsConfig, -) -> Element<'a, Message> { - let resolved = items.resolved(); - let default_resolved = defaults.resolved(); - let mut rows = column![text("Items").size(16)].spacing(8); - let mut current_surface = None; - let mut current_category = None; - - let unknown_count = resolved.unknown_hidden.len() + resolved.unknown_shown.len(); - if unknown_count > 0 { - rows = rows.push( - text(format!( - "Preserving {} unknown toolbar item id(s) from config.", - unknown_count - )) - .size(12), - ); - } - - for definition in toolbar_item_definitions_for_display(&resolved) { - if current_surface != Some(definition.surface) { - current_surface = Some(definition.surface); - current_category = None; - rows = rows.push(text(toolbar_item_surface_label(definition.surface)).size(14)); - } - if current_category != Some(definition.category) { - current_category = Some(definition.category); - rows = rows.push(text(toolbar_item_category_label(definition.category)).size(13)); - } - - rows = rows.push(toolbar_item_visibility_row( - definition, - &resolved, - &default_resolved, - )); - } - - rows.into() -} - -fn toolbar_item_visibility_row<'a>( - definition: &ToolbarItemDefinition, - resolved: &ResolvedToolbarItems, - defaults: &ResolvedToolbarItems, -) -> Element<'a, Message> { - let id = definition.id; - let visible = !resolved.is_hidden(id); - let built_in_default = format!( - "built-in default: {}", - visibility_override_label(!defaults.is_hidden(id)) - ); - let order_group = configurator_order_group(definition); - let order = order_group.and_then(|group| { - let index = resolved.order.index_of(group, id)?; - let len = resolved.order.ordered_ids(group).len(); - Some(( - group, - index, - index > 0, - index + 1 < len, - index == 0 && order_differs_from_built_in(resolved, group), - )) - }); - - let mut cells = row![ - checkbox(visible) - .label(definition.label) - .on_toggle(move |value| Message::ToolbarItemVisibilityChanged(id, value)), - text(definition.id.as_str()).size(12).width(Length::Fill), - ] - .spacing(12) - .align_y(iced::Alignment::Center); - if let Some((group, _, can_move_up, can_move_down, show_restore)) = order { - let up = if can_move_up { - button(text("^")).on_press(Message::ToolbarItemMoveRequested(group, id, -1)) - } else { - button(text("^")) - }; - let down = if can_move_down { - button(text("v")).on_press(Message::ToolbarItemMoveRequested(group, id, 1)) - } else { - button(text("v")) - }; - cells = cells.push(up).push(down); - if show_restore { - cells = cells.push( - button(text("Restore built-in order")) - .on_press(Message::ToolbarItemOrderReset(group)), - ); - } - } - cells.push(text(built_in_default).size(12)).into() -} - -fn order_differs_from_built_in( - resolved: &ResolvedToolbarItems, - group: ToolbarItemOrderGroup, -) -> bool { - let built_in = ToolbarItemsConfig::default().resolved(); - resolved.order.ordered_ids(group) != built_in.order.ordered_ids(group) -} - -fn toolbar_item_definitions_for_display( - resolved: &ResolvedToolbarItems, -) -> Vec<&'static ToolbarItemDefinition> { - let mut result = Vec::new(); - let mut emitted = BTreeSet::new(); - let mut emitted_groups = BTreeSet::new(); - - for definition in toolbar_item_definitions() { - if definition.id == wayscriber::config::toolbar_item_ids::SIDE_GROUP_SETTINGS - || definition.id == wayscriber::config::toolbar_item_ids::TOP_CHROME_OVERFLOW - { - continue; - } - if let Some(group) = configurator_order_group(definition) { - if emitted_groups.insert(group) { - for id in resolved.order.ordered_ids(group) { - if let Some(ordered_definition) = toolbar_item_definitions() - .iter() - .find(|candidate| candidate.id == *id) - && configurator_order_group(ordered_definition) == Some(group) - { - result.push(ordered_definition); - emitted.insert(ordered_definition.id); - } - } - } - if emitted.contains(&definition.id) { - continue; - } - } - result.push(definition); - emitted.insert(definition.id); - } - - result -} - -fn configurator_order_group(definition: &ToolbarItemDefinition) -> Option { - let group = toolbar_item_order_group(definition)?; - matches!( - group, - ToolbarItemOrderGroup::TopTools - | ToolbarItemOrderGroup::TopControls - | ToolbarItemOrderGroup::SideSections - ) - .then_some(group) -} - -fn visibility_override_label(visible: bool) -> &'static str { - if visible { "shown" } else { "hidden" } -} - -fn toolbar_item_surface_label(surface: ToolbarItemSurface) -> &'static str { - match surface { - ToolbarItemSurface::Top => "Top toolbar", - ToolbarItemSurface::Side => "Side toolbar", - } -} - -fn toolbar_item_category_label(category: ToolbarItemCategory) -> &'static str { - match category { - ToolbarItemCategory::Chrome => "Toolbar controls", - ToolbarItemCategory::Tool => "Tools", - ToolbarItemCategory::Utility => "Utilities", - ToolbarItemCategory::Group => "Sections", - ToolbarItemCategory::Action => "Actions", - ToolbarItemCategory::Page => "Pages", - ToolbarItemCategory::Board => "Boards", - ToolbarItemCategory::Setting => "Settings", - ToolbarItemCategory::Session => "Sessions", - ToolbarItemCategory::ToolOption => "Tool options", - } -} - -#[cfg(test)] -mod tests { - use super::*; - use wayscriber::config::toolbar_item_ids as ids; - - #[test] - fn restore_order_is_available_only_after_departing_from_built_in_order() { - let mut items = ToolbarItemsConfig::default(); - let group = ToolbarItemOrderGroup::TopTools; - assert!(!order_differs_from_built_in(&items.resolved(), group)); - - assert!(items.move_item_by(group, ids::TOP_TOOL_PEN, -1)); - assert!(order_differs_from_built_in(&items.resolved(), group)); - - assert!(items.reset_known_order_to_defaults(group)); - assert!(!order_differs_from_built_in(&items.resolved(), group)); - } -} diff --git a/configurator/src/app/view/widgets/color_picker.rs b/configurator/src/app/view/widgets/color_picker.rs deleted file mode 100644 index 4b884ae8..00000000 --- a/configurator/src/app/view/widgets/color_picker.rs +++ /dev/null @@ -1,19 +0,0 @@ -mod gradient; -mod inputs; -mod panel; - -pub(in crate::app::view) use gradient::color_gradient; -pub(in crate::app::view) use inputs::{ - color_quad_picker, color_rgb255_picker, color_triplet_picker, -}; -pub(in crate::app::view) use panel::picker_panel; - -use crate::models::ColorPickerId; - -#[derive(Clone, Copy)] -pub(in crate::app::view) struct ColorPickerUi<'a> { - pub id: ColorPickerId, - pub is_open: bool, - pub show_advanced: bool, - pub hex_value: &'a str, -} diff --git a/configurator/src/app/view/widgets/color_picker/gradient.rs b/configurator/src/app/view/widgets/color_picker/gradient.rs deleted file mode 100644 index 0494038f..00000000 --- a/configurator/src/app/view/widgets/color_picker/gradient.rs +++ /dev/null @@ -1,95 +0,0 @@ -use iced::border::Radius; -use iced::widget::{Space, button, column, container, row}; -use iced::{Background, Border, Color, Element, Length, Shadow}; - -use crate::app::view::theme; -use crate::messages::Message; -use crate::models::color::hsv_to_rgb; -use crate::models::{ColorPickerId, ColorPickerValue}; - -const GRADIENT_COLUMNS: usize = 18; -const GRADIENT_ROWS: usize = 6; -const CELL_HEIGHT: f32 = 15.0; - -pub(in crate::app::view) fn color_gradient<'a>( - id: ColorPickerId, - hue: f64, - saturation: f64, - value: f64, - alpha: Option, -) -> Element<'a, Message> { - let selected_col = selected_index(hue, GRADIENT_COLUMNS); - let selected_row = selected_index(1.0 - value, GRADIENT_ROWS); - - (0..GRADIENT_ROWS) - .fold(column![].spacing(2), |column, row_index| { - let row_value = 1.0 - normalized_step(row_index, GRADIENT_ROWS); - let row = (0..GRADIENT_COLUMNS).fold(row![].spacing(2), |row, col_index| { - let cell_hue = normalized_step(col_index, GRADIENT_COLUMNS); - let rgb = hsv_to_rgb(cell_hue, saturation, row_value); - let selected = row_index == selected_row && col_index == selected_col; - - row.push( - button(color_cell(rgb, selected)) - .padding(0) - .width(Length::FillPortion(1)) - .height(Length::Fixed(CELL_HEIGHT)) - .style(theme::Button::Subtle) - .on_press(Message::ColorPickerChanged( - id, - ColorPickerValue { rgb, alpha }, - )), - ) - }); - column.push(row) - }) - .into() -} - -fn color_cell<'a>(rgb: [f64; 3], selected: bool) -> Element<'a, Message> { - container( - Space::new() - .width(Length::Fill) - .height(Length::Fixed(CELL_HEIGHT)), - ) - .style(cell_style(rgb, selected)) - .into() -} - -fn cell_style(rgb: [f64; 3], selected: bool) -> impl Fn(&iced::Theme) -> container::Style { - move |_theme| container::Style { - background: Some(Background::Color(Color::from_rgb( - rgb[0] as f32, - rgb[1] as f32, - rgb[2] as f32, - ))), - text_color: None, - border: Border { - color: if selected { - Color::from_rgba(1.0, 1.0, 1.0, 0.95) - } else { - Color::from_rgba(0.0, 0.0, 0.0, 0.22) - }, - width: if selected { 2.0 } else { 1.0 }, - radius: Radius::from(2.0), - }, - shadow: Shadow::default(), - snap: true, - } -} - -fn normalized_step(index: usize, count: usize) -> f64 { - if count <= 1 { - 0.0 - } else { - index as f64 / (count - 1) as f64 - } -} - -fn selected_index(value: f64, count: usize) -> usize { - if count <= 1 { - 0 - } else { - (value.clamp(0.0, 1.0) * (count - 1) as f64).round() as usize - } -} diff --git a/configurator/src/app/view/widgets/color_picker/inputs.rs b/configurator/src/app/view/widgets/color_picker/inputs.rs deleted file mode 100644 index e9816be1..00000000 --- a/configurator/src/app/view/widgets/color_picker/inputs.rs +++ /dev/null @@ -1,209 +0,0 @@ -use iced::widget::{button, checkbox, column, row, text, text_input}; -use iced::{Alignment, Color, Element, Length, Theme}; - -use crate::messages::Message; -use crate::models::color::{parse_quad_values, parse_triplet_values, rgb_to_hsv}; -use crate::models::{ColorQuadInput, ColorTripletInput, QuadField, TripletField}; - -use super::super::colors::{color_preview_badge, color_preview_labeled}; -use super::super::constants::DEFAULT_LABEL_GAP; -use super::super::labels::default_value_text; -use super::ColorPickerUi; -use super::panel::picker_panel; - -fn input<'a>(placeholder: &'static str, value: &'a str) -> iced::widget::TextInput<'a, Message> { - text_input::(placeholder, value) -} - -pub(in crate::app::view) fn color_triplet_picker<'a>( - label: &'static str, - picker: ColorPickerUi<'a>, - triplet: &'a ColorTripletInput, - index: usize, - on_component: fn(usize, usize, String) -> Message, -) -> Element<'a, Message> { - let rgb = parse_triplet_values(&triplet.components); - let (hue, saturation, value) = rgb_to_hsv(rgb); - let preview = Color::from_rgb(rgb[0] as f32, rgb[1] as f32, rgb[2] as f32); - - let header = row![ - text(label).size(14), - color_preview_badge(Some(preview)), - input("HEX", picker.hex_value) - .on_input(move |val| Message::ColorPickerHexChanged(picker.id, val)) - .width(Length::Fixed(120.0)), - button(if picker.is_open { - "Hide picker" - } else { - "Pick" - }) - .on_press(Message::ColorPickerToggled(picker.id)), - checkbox(picker.show_advanced) - .label("Advanced") - .on_toggle(move |value| { Message::ColorPickerAdvancedToggled(picker.id, value) }), - ] - .spacing(8) - .align_y(Alignment::Center); - - let picker_panel: Element<'a, Message> = if picker.is_open { - picker_panel(picker.id, hue, saturation, value, rgb, None) - } else { - column![].into() - }; - - let advanced_inputs: Element<'a, Message> = if picker.show_advanced { - row![ - input("R", &triplet.components[0]).on_input(move |val| on_component(index, 0, val)), - input("G", &triplet.components[1]).on_input(move |val| on_component(index, 1, val)), - input("B", &triplet.components[2]).on_input(move |val| on_component(index, 2, val)), - ] - .spacing(8) - .align_y(Alignment::Center) - .into() - } else { - column![].into() - }; - - column![header, picker_panel, advanced_inputs] - .spacing(6) - .into() -} - -pub(in crate::app::view) fn color_rgb255_picker<'a>( - picker: ColorPickerUi<'a>, - components: &'a [String; 3], - preview: Option, - field: TripletField, -) -> Element<'a, Message> { - let rgb = parse_rgb255_values(components).unwrap_or([0.0, 0.0, 0.0]); - let (hue, saturation, value) = rgb_to_hsv(rgb); - - let picker_row = row![ - color_preview_labeled(preview), - input("HEX", picker.hex_value) - .on_input(move |val| Message::ColorPickerHexChanged(picker.id, val)) - .width(Length::Fixed(120.0)), - button(if picker.is_open { - "Hide picker" - } else { - "Pick" - }) - .on_press(Message::ColorPickerToggled(picker.id)), - ] - .spacing(8) - .align_y(Alignment::Center); - - let picker_panel: Element<'a, Message> = if picker.is_open { - picker_panel(picker.id, hue, saturation, value, rgb, None) - } else { - column![].into() - }; - - let component_row = row![ - input("R (0-255)", &components[0]) - .on_input(move |val| Message::TripletChanged(field, 0, val)) - .width(Length::Fixed(110.0)), - input("G (0-255)", &components[1]) - .on_input(move |val| Message::TripletChanged(field, 1, val)) - .width(Length::Fixed(110.0)), - input("B (0-255)", &components[2]) - .on_input(move |val| Message::TripletChanged(field, 2, val)) - .width(Length::Fixed(110.0)), - ] - .spacing(8) - .align_y(Alignment::Center); - - column![picker_row, picker_panel, component_row] - .spacing(6) - .into() -} - -pub(in crate::app::view) fn color_quad_picker<'a>( - label: &'static str, - picker: ColorPickerUi<'a>, - colors: &'a ColorQuadInput, - default: &'a ColorQuadInput, - field: QuadField, -) -> Element<'a, Message> { - let rgba = parse_quad_values(&colors.components); - let (hue, saturation, value) = rgb_to_hsv([rgba[0], rgba[1], rgba[2]]); - let preview = Color::from_rgba( - rgba[0] as f32, - rgba[1] as f32, - rgba[2] as f32, - rgba[3] as f32, - ); - let changed = colors != default; - - let label_row = row![ - text(label).size(14), - default_value_text(default.summary(), changed), - ] - .spacing(DEFAULT_LABEL_GAP) - .align_y(Alignment::Center); - - let header = row![ - color_preview_badge(Some(preview)), - input("HEX", picker.hex_value) - .on_input(move |val| Message::ColorPickerHexChanged(picker.id, val)) - .width(Length::Fixed(140.0)), - button(if picker.is_open { - "Hide picker" - } else { - "Pick" - }) - .on_press(Message::ColorPickerToggled(picker.id)), - checkbox(picker.show_advanced) - .label("Advanced") - .on_toggle(move |value| { Message::ColorPickerAdvancedToggled(picker.id, value) }), - ] - .spacing(8) - .align_y(Alignment::Center); - - let picker_panel: Element<'a, Message> = if picker.is_open { - picker_panel( - picker.id, - hue, - saturation, - value, - [rgba[0], rgba[1], rgba[2]], - Some(rgba[3]), - ) - } else { - column![].into() - }; - - let advanced_inputs: Element<'a, Message> = if picker.show_advanced { - row![ - input("Red", &colors.components[0]) - .on_input(move |val| Message::QuadChanged(field, 0, val)), - input("Green", &colors.components[1]) - .on_input(move |val| Message::QuadChanged(field, 1, val)), - input("Blue", &colors.components[2]) - .on_input(move |val| Message::QuadChanged(field, 2, val)), - input("Alpha", &colors.components[3]) - .on_input(move |val| Message::QuadChanged(field, 3, val)), - ] - .spacing(8) - .align_y(Alignment::Center) - .into() - } else { - column![].into() - }; - - column![label_row, header, picker_panel, advanced_inputs] - .spacing(6) - .into() -} - -fn parse_rgb255_values(components: &[String; 3]) -> Option<[f64; 3]> { - let mut rgb = [0.0; 3]; - for (index, component) in components.iter().enumerate() { - let parsed = component.trim().parse::().ok()?; - if !(0.0..=255.0).contains(&parsed) { - return None; - } - rgb[index] = parsed / 255.0; - } - Some(rgb) -} diff --git a/configurator/src/app/view/widgets/color_picker/panel.rs b/configurator/src/app/view/widgets/color_picker/panel.rs deleted file mode 100644 index e2e9370e..00000000 --- a/configurator/src/app/view/widgets/color_picker/panel.rs +++ /dev/null @@ -1,157 +0,0 @@ -use crate::app::view::theme; -use iced::border::Radius; -use iced::widget::{Space, button, column, container, row, slider, text}; -use iced::{Alignment, Background, Border, Color, Element, Length, Shadow}; - -use crate::messages::Message; -use crate::models::color::{hex_from_rgb, hsv_to_rgb}; -use crate::models::{ColorPickerId, ColorPickerValue}; - -use super::color_gradient; - -const COLOR_SLIDER_STEP: f32 = 0.001; -const PRESET_SWATCH_SIZE: f32 = 24.0; -const PRESET_SWATCH_GAP: f32 = 6.0; -const PRESET_COLORS: [(&str, [f64; 3]); 11] = [ - ("Red", [1.0, 0.0, 0.0]), - ("Green", [0.0, 1.0, 0.0]), - ("Blue", [0.0, 0.0, 1.0]), - ("Yellow", [1.0, 1.0, 0.0]), - ("White", [1.0, 1.0, 1.0]), - ("Black", [0.0, 0.0, 0.0]), - ("Orange", [1.0, 0.5, 0.0]), - ("Pink", [1.0, 0.0, 1.0]), - ("Cyan", [0.0, 1.0, 1.0]), - ("Purple", [0.6, 0.4, 0.8]), - ("Gray", [0.4, 0.4, 0.4]), -]; - -pub(in crate::app::view) fn picker_panel<'a>( - id: ColorPickerId, - hue: f64, - saturation: f64, - value: f64, - rgb: [f64; 3], - alpha: Option, -) -> Element<'a, Message> { - let saturation_slider = slider(0.0..=1.0, saturation as f32, move |val| { - Message::ColorPickerChanged( - id, - ColorPickerValue { - rgb: hsv_to_rgb(hue, val as f64, value), - alpha, - }, - ) - }) - .step(COLOR_SLIDER_STEP) - .width(Length::Fill); - - let mut column = column![ - color_gradient(id, hue, saturation, value, alpha), - row![ - color_swatch(rgb, false), - text(hex_from_rgb(rgb)).size(12), - Space::new().width(Length::Fill), - ] - .spacing(8) - .align_y(Alignment::Center), - preset_palette(id, rgb, alpha), - row![text("Saturation").size(12), saturation_slider] - .spacing(8) - .align_y(Alignment::Center), - ] - .spacing(8); - - if let Some(alpha_value) = alpha { - let slider = slider(0.0..=1.0, alpha_value as f32, move |val| { - Message::ColorPickerChanged( - id, - ColorPickerValue { - rgb, - alpha: Some(val as f64), - }, - ) - }) - .step(COLOR_SLIDER_STEP) - .width(Length::Fill); - - column = column.push( - row![text("Alpha").size(12), slider] - .spacing(8) - .align_y(Alignment::Center), - ); - } - - container(column) - .padding(10) - .style(theme::Container::Box) - .into() -} - -fn preset_palette<'a>( - id: ColorPickerId, - current_rgb: [f64; 3], - alpha: Option, -) -> Element<'a, Message> { - PRESET_COLORS - .chunks(6) - .fold(column![].spacing(PRESET_SWATCH_GAP), |column, chunk| { - let row = chunk.iter().fold( - row![].spacing(PRESET_SWATCH_GAP).align_y(Alignment::Center), - |row, (_label, rgb)| { - row.push( - button(color_swatch(*rgb, colors_approx_equal(*rgb, current_rgb))) - .padding(0) - .on_press(Message::ColorPickerChanged( - id, - ColorPickerValue { rgb: *rgb, alpha }, - )), - ) - }, - ); - column.push(row) - }) - .into() -} - -fn color_swatch<'a>(rgb: [f64; 3], selected: bool) -> Element<'a, Message> { - container( - Space::new() - .width(Length::Fixed(PRESET_SWATCH_SIZE)) - .height(Length::Fixed(PRESET_SWATCH_SIZE)), - ) - .style(swatch_style(rgb, selected)) - .into() -} - -fn swatch_style(rgb: [f64; 3], selected: bool) -> impl Fn(&iced::Theme) -> container::Style { - move |_theme| { - let color = Color::from_rgb(rgb[0] as f32, rgb[1] as f32, rgb[2] as f32); - let luminance = 0.299 * rgb[0] + 0.587 * rgb[1] + 0.114 * rgb[2]; - let border_color = if selected { - Color::from_rgb(0.95, 0.95, 0.95) - } else if luminance < 0.3 { - Color::from_rgba(0.65, 0.68, 0.72, 0.85) - } else { - Color::from_rgba(0.12, 0.13, 0.15, 0.75) - }; - - container::Style { - background: Some(Background::Color(color)), - text_color: None, - border: Border { - color: border_color, - width: if selected { 2.0 } else { 1.0 }, - radius: Radius::from(4.0), - }, - shadow: Shadow::default(), - snap: true, - } - } -} - -fn colors_approx_equal(left: [f64; 3], right: [f64; 3]) -> bool { - left.iter() - .zip(right.iter()) - .all(|(left, right)| (left - right).abs() <= 0.01) -} diff --git a/configurator/src/app/view/widgets/colors.rs b/configurator/src/app/view/widgets/colors.rs deleted file mode 100644 index da6909bb..00000000 --- a/configurator/src/app/view/widgets/colors.rs +++ /dev/null @@ -1,71 +0,0 @@ -use crate::app::view::theme; -use iced::border::Radius; -use iced::widget::{Space, column, container, text}; -use iced::{Background, Border, Element, Length, alignment}; - -use crate::messages::Message; - -pub(in crate::app::view) fn color_preview_badge<'a>( - color: Option, -) -> Element<'a, Message> { - let (preview_color, is_valid) = match color { - Some(color) => (color, true), - None => (iced::Color::from_rgb(0.2, 0.2, 0.2), false), - }; - - let content: Element<'_, Message> = if is_valid { - Space::new() - .width(Length::Shrink) - .height(Length::Shrink) - .into() - } else { - text("?") - .size(14) - .style(theme::Text::Color(iced::Color::from_rgb(0.95, 0.95, 0.95))) - .into() - }; - - container(content) - .width(Length::Fixed(24.0)) - .height(Length::Fixed(24.0)) - .align_x(alignment::Horizontal::Center) - .align_y(alignment::Vertical::Center) - .style(color_preview_style(ColorPreviewStyle { - color: preview_color, - is_invalid: !is_valid, - })) - .into() -} - -pub(in crate::app::view) fn color_preview_labeled<'a>( - color: Option, -) -> Element<'a, Message> { - column![text("Preview").size(12), color_preview_badge(color)] - .spacing(2) - .align_x(iced::Alignment::Center) - .into() -} - -#[derive(Clone, Copy)] -struct ColorPreviewStyle { - color: iced::Color, - is_invalid: bool, -} - -fn color_preview_style(style: ColorPreviewStyle) -> impl Fn(&iced::Theme) -> container::Style { - move |_theme| container::Style { - background: Some(Background::Color(style.color)), - text_color: None, - border: Border { - color: if style.is_invalid { - iced::Color::from_rgb(0.9, 0.4, 0.4) - } else { - iced::Color::from_rgb(0.4, 0.4, 0.4) - }, - width: 1.0, - radius: Radius::from(6.0), - }, - shadow: Default::default(), - snap: true, - } -} diff --git a/configurator/src/app/view/widgets/constants.rs b/configurator/src/app/view/widgets/constants.rs deleted file mode 100644 index b6a4025b..00000000 --- a/configurator/src/app/view/widgets/constants.rs +++ /dev/null @@ -1,5 +0,0 @@ -pub(in crate::app::view) const DEFAULT_LABEL_GAP: f32 = 12.0; -pub(in crate::app::view) const LABEL_COLUMN_WIDTH: f32 = 220.0; -pub(in crate::app::view) const SMALL_PICKER_WIDTH: f32 = 140.0; -pub(in crate::app::view) const COLOR_PICKER_WIDTH: f32 = 160.0; -pub(in crate::app::view) const BUFFER_PICKER_WIDTH: f32 = 120.0; diff --git a/configurator/src/app/view/widgets/inputs.rs b/configurator/src/app/view/widgets/inputs.rs deleted file mode 100644 index 04db98f5..00000000 --- a/configurator/src/app/view/widgets/inputs.rs +++ /dev/null @@ -1,182 +0,0 @@ -use iced::widget::{checkbox, column, pick_list, row, text, text_input}; -use iced::{Element, Length}; - -use crate::messages::Message; -use crate::models::{ - OverrideOption, PresetTextField, PresetToggleField, TextField, ToggleField, - ToolbarOverrideField, -}; - -use super::constants::{DEFAULT_LABEL_GAP, SMALL_PICKER_WIDTH}; -use super::labels::{default_value_text, feedback_text}; - -pub(in crate::app::view) fn labeled_input<'a>( - label: &'static str, - value: &'a str, - default: &'a str, - field: TextField, -) -> Element<'a, Message> { - labeled_input_with_feedback(label, value, default, field, None, None) -} - -pub(in crate::app::view) fn labeled_input_with_feedback<'a>( - label: &'static str, - value: &'a str, - default: &'a str, - field: TextField, - hint: Option<&'static str>, - error: Option, -) -> Element<'a, Message> { - let changed = value.trim() != default.trim(); - let input = text_input(label, value).on_input(move |val| Message::TextChanged(field, val)); - let mut column = column![ - row![text(label).size(14), default_value_text(default, changed)] - .spacing(DEFAULT_LABEL_GAP) - .align_y(iced::Alignment::Center), - input - ] - .spacing(4) - .width(Length::Fill); - - if let Some(message) = error { - column = column.push(feedback_text(message, true)); - } else if let Some(message) = hint { - column = column.push(feedback_text(message, false)); - } - - column.into() -} - -pub(in crate::app::view) fn labeled_input_state<'a>( - label: &'static str, - value: &'a str, - default: &'a str, - field: TextField, - enabled: bool, - hint: Option<&'static str>, - error: Option, -) -> Element<'a, Message> { - let changed = value.trim() != default.trim(); - let input = if enabled { - text_input(label, value).on_input(move |val| Message::TextChanged(field, val)) - } else { - text_input(label, value) - }; - - let mut column = column![ - row![text(label).size(14), default_value_text(default, changed)] - .spacing(DEFAULT_LABEL_GAP) - .align_y(iced::Alignment::Center), - input - ] - .spacing(4) - .width(Length::Fill); - - if let Some(message) = error { - column = column.push(feedback_text(message, true)); - } else if let Some(message) = hint { - column = column.push(feedback_text(message, false)); - } - - column.into() -} - -pub(in crate::app::view) fn labeled_control<'a>( - label: &'static str, - control: Element<'a, Message>, - default: impl Into, - changed: bool, -) -> Element<'a, Message> { - column![ - row![text(label).size(14), default_value_text(default, changed)] - .spacing(DEFAULT_LABEL_GAP) - .align_y(iced::Alignment::Center), - control - ] - .spacing(4) - .width(Length::Fill) - .into() -} - -pub(in crate::app::view) fn preset_input<'a>( - label: &'static str, - value: &'a str, - default: &'a str, - slot_index: usize, - field: PresetTextField, - show_unset: bool, -) -> Element<'a, Message> { - let changed = value.trim() != default.trim(); - let default_label = if show_unset && default.trim().is_empty() { - "unset".to_string() - } else { - default.trim().to_string() - }; - - column![ - row![ - text(label).size(14), - default_value_text(default_label, changed) - ] - .spacing(DEFAULT_LABEL_GAP) - .align_y(iced::Alignment::Center), - text_input(label, value) - .on_input(move |val| Message::PresetTextChanged(slot_index, field, val)) - ] - .spacing(4) - .width(Length::Fill) - .into() -} - -pub(in crate::app::view) fn preset_override_control<'a>( - label: &'static str, - value: OverrideOption, - default: OverrideOption, - slot_index: usize, - field: PresetToggleField, -) -> Element<'a, Message> { - let picker = pick_list(OverrideOption::list(), Some(value), move |opt| { - Message::PresetToggleOptionChanged(slot_index, field, opt) - }) - .width(Length::Fill); - - labeled_control(label, picker.into(), default.label(), value != default) -} - -pub(in crate::app::view) fn override_row<'a>( - field: ToolbarOverrideField, - value: OverrideOption, -) -> Element<'a, Message> { - row![ - text(field.label()).size(14), - pick_list(OverrideOption::list(), Some(value), move |opt| { - Message::ToolbarOverrideChanged(field, opt) - },) - .width(Length::Fixed(SMALL_PICKER_WIDTH)), - ] - .spacing(12) - .align_y(iced::Alignment::Center) - .into() -} - -pub(in crate::app::view) fn bool_label(value: bool) -> &'static str { - if value { "On" } else { "Off" } -} - -pub(in crate::app::view) fn toggle_row<'a>( - label: &'static str, - value: bool, - default: bool, - field: ToggleField, -) -> Element<'a, Message> { - let changed = value != default; - row![ - checkbox(value) - .label(label) - .on_toggle(move |val| Message::ToggleChanged(field, val)), - default_value_text(bool_label(default), changed), - ] - .spacing(DEFAULT_LABEL_GAP) - .align_y(iced::Alignment::Center) - .into() -} diff --git a/configurator/src/app/view/widgets/labels.rs b/configurator/src/app/view/widgets/labels.rs deleted file mode 100644 index 860605ad..00000000 --- a/configurator/src/app/view/widgets/labels.rs +++ /dev/null @@ -1,33 +0,0 @@ -use crate::app::view::theme; -use iced::widget::text; - -pub(in crate::app::view) fn default_label_color(changed: bool) -> iced::Color { - if changed { - iced::Color::from_rgb(0.95, 0.6, 0.2) - } else { - iced::Color::from_rgb(0.65, 0.74, 0.82) - } -} - -pub(in crate::app::view) fn default_value_text<'a>( - value: impl Into, - changed: bool, -) -> iced::widget::Text<'a> { - let label = format!("Default: {}", value.into()); - text(label) - .size(12) - .style(theme::Text::Color(default_label_color(changed))) -} - -pub(in crate::app::view) fn feedback_text<'a>( - message: impl Into, - is_error: bool, -) -> iced::widget::Text<'a> { - let color = if is_error { - iced::Color::from_rgb(0.95, 0.6, 0.6) - } else { - iced::Color::from_rgb(0.6, 0.6, 0.6) - }; - let message = message.into(); - text(message).size(12).style(theme::Text::Color(color)) -} diff --git a/configurator/src/app/view/widgets/mod.rs b/configurator/src/app/view/widgets/mod.rs deleted file mode 100644 index 7b99d646..00000000 --- a/configurator/src/app/view/widgets/mod.rs +++ /dev/null @@ -1,24 +0,0 @@ -mod color_picker; -mod colors; -mod constants; -mod inputs; -mod labels; -mod validation; - -pub(super) use color_picker::{ - ColorPickerUi, color_quad_picker, color_rgb255_picker, color_triplet_picker, picker_panel, -}; -pub(super) use colors::{color_preview_badge, color_preview_labeled}; -pub(super) use constants::{ - BUFFER_PICKER_WIDTH, COLOR_PICKER_WIDTH, DEFAULT_LABEL_GAP, LABEL_COLUMN_WIDTH, - SMALL_PICKER_WIDTH, -}; -pub(super) use inputs::{ - bool_label, labeled_control, labeled_input, labeled_input_state, labeled_input_with_feedback, - override_row, preset_input, preset_override_control, toggle_row, -}; -pub(super) use labels::{default_label_color, default_value_text, feedback_text}; -pub(super) use validation::{ - validate_f64_range, validate_u32_range, validate_u64_min, validate_u64_range, - validate_usize_min, validate_usize_range, -}; diff --git a/configurator/src/app/view/widgets/validation.rs b/configurator/src/app/view/widgets/validation.rs deleted file mode 100644 index 286e8532..00000000 --- a/configurator/src/app/view/widgets/validation.rs +++ /dev/null @@ -1,171 +0,0 @@ -use crate::models::util::format_float; - -pub(in crate::app::view) fn validate_f64_range(value: &str, min: f64, max: f64) -> Option { - let trimmed = value.trim(); - if trimmed.is_empty() { - return Some("Expected a numeric value".to_string()); - } - - match trimmed.parse::() { - Ok(value) => { - if value < min || value > max { - Some(format!( - "Range: {}-{}", - format_float(min), - format_float(max) - )) - } else { - None - } - } - Err(_) => Some("Expected a numeric value".to_string()), - } -} - -pub(in crate::app::view) fn validate_u32_range(value: &str, min: u32, max: u32) -> Option { - let trimmed = value.trim(); - if trimmed.is_empty() { - return Some("Expected a whole number".to_string()); - } - - match trimmed.parse::() { - Ok(value) => { - if value < min || value > max { - Some(format!("Range: {min}-{max}")) - } else { - None - } - } - Err(_) => Some("Expected a whole number".to_string()), - } -} - -pub(in crate::app::view) fn validate_u64_range(value: &str, min: u64, max: u64) -> Option { - let trimmed = value.trim(); - if trimmed.is_empty() { - return Some("Expected a whole number".to_string()); - } - - match trimmed.parse::() { - Ok(value) => { - if value < min || value > max { - Some(format!("Range: {min}-{max}")) - } else { - None - } - } - Err(_) => Some("Expected a whole number".to_string()), - } -} - -pub(in crate::app::view) fn validate_u64_min(value: &str, min: u64) -> Option { - let trimmed = value.trim(); - if trimmed.is_empty() { - return Some("Expected a whole number".to_string()); - } - - match trimmed.parse::() { - Ok(value) => { - if value < min { - Some(format!("Minimum: {min}")) - } else { - None - } - } - Err(_) => Some("Expected a whole number".to_string()), - } -} - -pub(in crate::app::view) fn validate_usize_range( - value: &str, - min: usize, - max: usize, -) -> Option { - let trimmed = value.trim(); - if trimmed.is_empty() { - return Some("Expected a whole number".to_string()); - } - - match trimmed.parse::() { - Ok(value) => { - if value < min || value > max { - Some(format!("Range: {min}-{max}")) - } else { - None - } - } - Err(_) => Some("Expected a whole number".to_string()), - } -} - -pub(in crate::app::view) fn validate_usize_min(value: &str, min: usize) -> Option { - let trimmed = value.trim(); - if trimmed.is_empty() { - return Some("Expected a whole number".to_string()); - } - - match trimmed.parse::() { - Ok(value) => { - if value < min { - Some(format!("Minimum: {min}")) - } else { - None - } - } - Err(_) => Some("Expected a whole number".to_string()), - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn validate_f64_range_accepts_bounds_and_rejects_outside() { - assert_eq!(validate_f64_range("0.5", 0.5, 2.0), None); - assert_eq!(validate_f64_range("2.0", 0.5, 2.0), None); - assert_eq!( - validate_f64_range("2.1", 0.5, 2.0), - Some("Range: 0.5-2".to_string()) - ); - assert_eq!( - validate_f64_range("", 0.5, 2.0), - Some("Expected a numeric value".to_string()) - ); - } - - #[test] - fn validate_u32_range_reports_expected_errors() { - assert_eq!(validate_u32_range("10", 1, 100), None); - assert_eq!( - validate_u32_range("0", 1, 100), - Some("Range: 1-100".to_string()) - ); - assert_eq!( - validate_u32_range("abc", 1, 100), - Some("Expected a whole number".to_string()) - ); - } - - #[test] - fn validate_u64_helpers_enforce_range_and_minimum() { - assert_eq!(validate_u64_range("5", 1, 10), None); - assert_eq!( - validate_u64_range("11", 1, 10), - Some("Range: 1-10".to_string()) - ); - assert_eq!(validate_u64_min("10", 10), None); - assert_eq!(validate_u64_min("9", 10), Some("Minimum: 10".to_string())); - } - - #[test] - fn validate_usize_helpers_enforce_range_and_minimum() { - assert_eq!(validate_usize_range("3", 1, 5), None); - assert_eq!( - validate_usize_range("6", 1, 5), - Some("Range: 1-5".to_string()) - ); - assert_eq!(validate_usize_min("3", 3), None); - assert_eq!(validate_usize_min("2", 3), Some("Minimum: 3".to_string())); - } -} diff --git a/configurator/src/main.rs b/configurator/src/main.rs index dc7d7b00..e659c6d4 100644 --- a/configurator/src/main.rs +++ b/configurator/src/main.rs @@ -8,11 +8,11 @@ mod test_temp; use models::{StartupRequest, startup_usage}; -fn main() -> iced::Result { +fn main() { let startup = StartupRequest::from_args(std::env::args_os()); if startup.wants_help() { print!("{}", startup_usage()); - return Ok(()); + return; } app::run(startup) diff --git a/configurator/src/messages.rs b/configurator/src/messages.rs index e135db98..f1d03783 100644 --- a/configurator/src/messages.rs +++ b/configurator/src/messages.rs @@ -1,43 +1,65 @@ use std::path::PathBuf; -use std::sync::Arc; use wayscriber::config::{ConfigDocument, ToolbarItemId, ToolbarItemOrderGroup}; use crate::models::{ BoardBackgroundOption, BoardItemTextField, BoardItemToggleField, ColorMode, ColorPickerId, - ColorPickerValue, DaemonAction, DaemonActionResult, DaemonRuntimeStatus, DragColorOption, - DragMouseButton, DragToolField, DragToolOption, EraserModeOption, FontStyleOption, - FontWeightOption, InputHudModeOption, InputHudPositionOption, KeybindingField, - KeybindingsTabId, NamedColorOption, OverrideOption, PdfFitModeOption, - PdfLabelContentModeOption, PdfLabelPositionOption, PdfOrientationOption, PdfPageSizeOption, + DaemonAction, DaemonActionResult, DaemonRuntimeStatus, DragColorOption, DragMouseButton, + DragToolField, DragToolOption, EraserModeOption, FontStyleOption, FontWeightOption, + InputHudModeOption, InputHudPositionOption, KeybindingField, KeybindingsTabId, + NamedColorOption, OverrideOption, PdfFitModeOption, PdfLabelContentModeOption, + PdfLabelPositionOption, PdfOrientationOption, PdfPageSizeOption, PdfTransparentBackgroundOption, PresenterToolBehaviorOption, PresenterToolbarModeOption, - PresetEraserKindOption, PresetEraserModeOption, PresetTextField, PresetToggleField, QuadField, + PresetEraserKindOption, PresetEraserModeOption, PresetTextField, PresetToggleField, ReducedMotionOption, RenderProfileExportOption, RenderProfileMappingSide, RenderProfileTextField, SessionCatalogActionResult, SessionCatalogItem, SessionCompressionOption, SessionStorageModeOption, StatusPositionOption, TabId, TextField, ToggleField, ToolOption, ToolbarLayoutModeOption, ToolbarOverrideField, - ToolbarRebindModifierOption, ToolbarSideLayoutOption, TripletField, UiTabId, UiThemeOption, + ToolbarRebindModifierOption, ToolbarSideLayoutOption, UiTabId, UiThemeOption, ZoomChipDisplayOption, }; #[cfg(feature = "tablet-input")] use crate::models::{PressureThicknessEditModeOption, PressureThicknessEntryModeOption}; +/// What a finished write reports back: the document it produced and any backup +/// it made, or why nothing was written. +/// +/// Both arms carry a document, because the model gave its only copy away to +/// start the write and needs one back. The failure arm's is `None` in exactly +/// one case: a blocking job that never returned took the document it was +/// holding with it, leaving a reload as the way forward. +pub type ConfigSaveResult = + Result<(Option, Box), (Option>, String)>; + +/// What a finished `Effect` reports back. +/// +/// Separate from [`Message`] because none of these can come from a widget: +/// they are the results of jobs the update layer asked for, they carry values +/// (the config document above all) that are moved rather than copied, and +/// nothing may clone them. +#[derive(Debug)] +pub enum CommandMessage { + ConfigLoaded(Result<(Box, Option), String>), + ConfigSaved(ConfigSaveResult), + DaemonStatusLoaded(u64, Result), + DaemonActionCompleted(Result), + SessionCatalogLoaded(Result, String>), + SessionCatalogActionCompleted(Result), +} + +/// What the user asked for, always from a widget. +/// +/// Cloned by the page builders that hand one message to a button closure, so +/// every payload here stays cheap to copy. #[derive(Debug, Clone)] pub enum Message { - ConfigLoaded(Result<(Arc, Option), String>), ReloadRequested, ResetToDefaultsRequested, - ResetToDefaultsConfirmed, - ResetToDefaultsCanceled, SaveRequested, - ConfigSaved(Result<(Option, Arc), String>), MigrationApplyRequested, MigrationDismissed, - DaemonStatusLoaded(u64, Result), DaemonShortcutInputChanged(String), DaemonActionRequested(DaemonAction), - DaemonActionCompleted(Result), - SessionCatalogLoaded(Result, String>), SessionCatalogRefreshRequested, SessionCatalogForgetRequested(String), SessionCatalogRenameInputChanged(String, String), @@ -51,24 +73,18 @@ pub enum Message { SessionCatalogClearRequested(String), SessionCatalogClearConfirmed(String), SessionCatalogClearCanceled, - SessionCatalogActionCompleted(Result), SearchChanged(String), SearchCleared, SearchFocusRequested, - SearchFocusObserved(bool), - KeyboardEvent(iced::keyboard::Event, iced::event::Status), - PointerPressed, + /// The user clicked, tapped, or Tab-navigated before the initial config + /// load landed; any still-pending startup search focus stands down. + StartupInteractionObserved, TabSelected(TabId), UiTabSelected(UiTabId), KeybindingsTabSelected(KeybindingsTabId), ToggleChanged(ToggleField, bool), TextChanged(TextField, String), - TripletChanged(TripletField, usize, String), - QuadChanged(QuadField, usize, String), - ColorPickerToggled(ColorPickerId), - ColorPickerAdvancedToggled(ColorPickerId, bool), ColorPickerHexChanged(ColorPickerId, String), - ColorPickerChanged(ColorPickerId, ColorPickerValue), ColorModeChanged(ColorMode), NamedColorSelected(NamedColorOption), QuickColorAdded, diff --git a/configurator/src/models/color/convert.rs b/configurator/src/models/color/convert.rs index a25897ad..5d392ab3 100644 --- a/configurator/src/models/color/convert.rs +++ b/configurator/src/models/color/convert.rs @@ -27,57 +27,6 @@ pub fn parse_quad_values(values: &[String; 4]) -> [f64; 4] { ] } -pub fn rgb_to_hsv(rgb: [f64; 3]) -> (f64, f64, f64) { - let r = clamp01(rgb[0]); - let g = clamp01(rgb[1]); - let b = clamp01(rgb[2]); - - let max = r.max(g).max(b); - let min = r.min(g).min(b); - let delta = max - min; - - let h = if delta == 0.0 { - 0.0 - } else if max == r { - ((g - b) / delta).rem_euclid(6.0) / 6.0 - } else if max == g { - ((b - r) / delta + 2.0) / 6.0 - } else { - ((r - g) / delta + 4.0) / 6.0 - }; - - let s = if max == 0.0 { 0.0 } else { delta / max }; - let v = max; - - (clamp01(h), clamp01(s), clamp01(v)) -} - -pub fn hsv_to_rgb(h: f64, s: f64, v: f64) -> [f64; 3] { - let h = clamp01(h); - let s = clamp01(s); - let v = clamp01(v); - - if s == 0.0 { - return [v, v, v]; - } - - let h6 = h * 6.0; - let i = h6.floor(); - let f = h6 - i; - let p = v * (1.0 - s); - let q = v * (1.0 - s * f); - let t = v * (1.0 - s * (1.0 - f)); - - match (i as i32).rem_euclid(6) { - 0 => [v, t, p], - 1 => [q, v, p], - 2 => [p, v, t], - 3 => [p, q, v], - 4 => [t, p, v], - _ => [v, p, q], - } -} - fn to_u8(value: f64) -> u8 { let clamped = clamp01(value); (clamped * 255.0).round().clamp(0.0, 255.0) as u8 diff --git a/configurator/src/models/color/input.rs b/configurator/src/models/color/input.rs index eeb9ab93..043b79a7 100644 --- a/configurator/src/models/color/input.rs +++ b/configurator/src/models/color/input.rs @@ -1,5 +1,5 @@ -use iced::Color; use wayscriber::config::enums::ColorSpec; +use wayscriber::draw::Color; use wayscriber::util::{ConfigHexColorError, name_to_color, parse_config_hex_color}; use super::super::error::FormError; @@ -118,7 +118,7 @@ impl ColorInput { let Some(color) = self.preview_color() else { return false; }; - self.rgb = iced_color_to_rgb_strings(color); + self.rgb = color_to_rgb_strings(color); true } @@ -136,32 +136,30 @@ impl ColorInput { } match parse_config_hex_color(&name) { - Ok(color) => Some(to_iced_color(color)), - Err(ConfigHexColorError::MissingHash) => { - name_to_color(&name).map(to_iced_color) - } + Ok(color) => Some(color), + Err(ConfigHexColorError::MissingHash) => name_to_color(&name), Err(_) => None, } } ColorMode::Rgb => { - let mut components = [0.0f32; 3]; + let mut components = [0.0f64; 3]; for (index, value) in self.rgb.iter().enumerate() { let trimmed = value.trim(); if trimmed.is_empty() { return None; } - let parsed = trimmed.parse::().ok()?; + let parsed = trimmed.parse::().ok()?; if !(0.0..=255.0).contains(&parsed) { return None; } components[index] = parsed / 255.0; } - Some(Color::from_rgba( - components[0], - components[1], - components[2], - 1.0, - )) + Some(Color { + r: components[0], + g: components[1], + b: components[2], + a: 1.0, + }) } } } @@ -198,24 +196,8 @@ impl ColorInput { } } -fn to_iced_color(color: wayscriber::draw::Color) -> Color { - Color::from_rgba( - color.r as f32, - color.g as f32, - color.b as f32, - color.a as f32, - ) -} - -fn color_to_rgb_strings(color: wayscriber::draw::Color) -> [String; 3] { - [ - ((color.r.clamp(0.0, 1.0) * 255.0).round() as u8).to_string(), - ((color.g.clamp(0.0, 1.0) * 255.0).round() as u8).to_string(), - ((color.b.clamp(0.0, 1.0) * 255.0).round() as u8).to_string(), - ] -} - -fn iced_color_to_rgb_strings(color: Color) -> [String; 3] { +/// The three RGB boxes for a color, as the byte strings the fields hold. +fn color_to_rgb_strings(color: Color) -> [String; 3] { [ ((color.r.clamp(0.0, 1.0) * 255.0).round() as u8).to_string(), ((color.g.clamp(0.0, 1.0) * 255.0).round() as u8).to_string(), diff --git a/configurator/src/models/color/mod.rs b/configurator/src/models/color/mod.rs index d0bd3777..dac8975b 100644 --- a/configurator/src/models/color/mod.rs +++ b/configurator/src/models/color/mod.rs @@ -3,15 +3,16 @@ mod input; mod named; mod quad; mod triplet; +mod validation; #[cfg(test)] mod tests; pub use convert::{ - hex_from_rgb, hex_from_rgba, hsv_to_rgb, parse_hex, parse_quad_values, parse_triplet_values, - rgb_to_hsv, + hex_from_rgb, hex_from_rgba, parse_hex, parse_quad_values, parse_triplet_values, }; pub use input::ColorInput; pub use named::{ColorMode, NamedColorOption}; pub use quad::ColorQuadInput; pub use triplet::ColorTripletInput; +pub(crate) use validation::hex_field_error; diff --git a/configurator/src/models/color/quad.rs b/configurator/src/models/color/quad.rs index e81b6cdf..4c08e725 100644 --- a/configurator/src/models/color/quad.rs +++ b/configurator/src/models/color/quad.rs @@ -40,14 +40,4 @@ impl ColorQuadInput { } Ok(out) } - - pub fn summary(&self) -> String { - [ - self.components[0].trim(), - self.components[1].trim(), - self.components[2].trim(), - self.components[3].trim(), - ] - .join(", ") - } } diff --git a/configurator/src/models/color/tests.rs b/configurator/src/models/color/tests.rs index 66c0d575..f8cb371c 100644 --- a/configurator/src/models/color/tests.rs +++ b/configurator/src/models/color/tests.rs @@ -34,9 +34,9 @@ fn color_input_hex_preview_and_known_conversion() { assert_eq!(input.rgb, ["255", "179", "186"]); let preview = input.preview_color().expect("hex preview should resolve"); - assert!((preview.r - 1.0).abs() < f32::EPSILON); - assert!((preview.g - (179.0f32 / 255.0)).abs() < f32::EPSILON); - assert!((preview.b - (186.0f32 / 255.0)).abs() < f32::EPSILON); + assert!((preview.r - 1.0).abs() < f64::EPSILON); + assert!((preview.g - (179.0 / 255.0)).abs() < f64::EPSILON); + assert!((preview.b - (186.0 / 255.0)).abs() < f64::EPSILON); let round_trip = input .to_known_color_spec_with_field("drawing.quick_colors[0].color") @@ -173,20 +173,6 @@ fn color_triplet_input_reports_invalid_component() { assert_eq!(err.field, "board.whiteboard_color[1]"); } -#[test] -fn color_quad_input_summary_trims_components() { - let input = ColorQuadInput { - components: [ - " 0.1 ".to_string(), - "0.2".to_string(), - " 0.3".to_string(), - "0.4 ".to_string(), - ], - }; - - assert_eq!(input.summary(), "0.1, 0.2, 0.3, 0.4"); -} - #[test] fn color_quad_input_rejects_out_of_range_component() { let input = ColorQuadInput { diff --git a/configurator/src/models/color/validation.rs b/configurator/src/models/color/validation.rs new file mode 100644 index 00000000..23759b23 --- /dev/null +++ b/configurator/src/models/color/validation.rs @@ -0,0 +1,53 @@ +use super::parse_hex; + +/// What a color field says about text the save gate refuses. Kept short +/// because it shows as a tooltip on a field the user is still typing in. +const INVALID_HEX_MESSAGE: &str = "Not a hex color"; + +/// What an emptied color field says. Longer than the rejection message +/// because a blank field has nothing in it to point at. +const EMPTY_HEX_MESSAGE: &str = "Enter a color as #RRGGBB or #RRGGBBAA"; + +/// The message for hex text the model's save gate refuses, `None` while the +/// field holds a color. +/// +/// Empty is invalid. Every picker edits a required color, so a cleared field +/// is not a value the save can write: without this, saving would silently keep +/// the last parsed value and discard the cleared text on reload. +pub(crate) fn hex_field_error(text: &str) -> Option<&'static str> { + let trimmed = text.trim(); + if trimmed.is_empty() { + return Some(EMPTY_HEX_MESSAGE); + } + if parse_hex(trimmed).is_some() { + return None; + } + Some(INVALID_HEX_MESSAGE) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn hex_field_error_accepts_what_the_save_gate_accepts() { + assert_eq!(hex_field_error("#00FF80"), None); + assert_eq!(hex_field_error("00ff80"), None); + assert_eq!(hex_field_error("#00FF80FF"), None); + } + + #[test] + fn hex_field_error_reports_text_the_save_gate_refuses() { + assert_eq!(hex_field_error("#00FF8"), Some(INVALID_HEX_MESSAGE)); + assert_eq!(hex_field_error("nope"), Some(INVALID_HEX_MESSAGE)); + assert_eq!(hex_field_error("#GGGGGG"), Some(INVALID_HEX_MESSAGE)); + } + + /// An emptied field is a color the save cannot write, so it is refused + /// like any other text that is not a color, with its own actionable text. + #[test] + fn hex_field_error_refuses_an_emptied_field() { + assert_eq!(hex_field_error(""), Some(EMPTY_HEX_MESSAGE)); + assert_eq!(hex_field_error(" "), Some(EMPTY_HEX_MESSAGE)); + } +} diff --git a/configurator/src/models/color_picker.rs b/configurator/src/models/color_picker.rs index a39c790a..d024bd64 100644 --- a/configurator/src/models/color_picker.rs +++ b/configurator/src/models/color_picker.rs @@ -17,8 +17,20 @@ pub enum ColorPickerId { ExportPdfLabelBackground, } -#[derive(Debug, Clone, Copy)] -pub struct ColorPickerValue { - pub rgb: [f64; 3], - pub alpha: Option, +impl ColorPickerId { + /// Whether this picker edits a four-component color rather than RGB. + pub(crate) fn uses_alpha(self) -> bool { + matches!( + self, + Self::StatusBarBg + | Self::StatusBarText + | Self::HighlightFill + | Self::HighlightOutline + | Self::HelpBg + | Self::HelpBorder + | Self::HelpText + | Self::ExportPdfLabelText + | Self::ExportPdfLabelBackground + ) + } } diff --git a/configurator/src/models/config/render_profiles.rs b/configurator/src/models/config/render_profiles.rs index 1d62a263..48094f9b 100644 --- a/configurator/src/models/config/render_profiles.rs +++ b/configurator/src/models/config/render_profiles.rs @@ -80,13 +80,6 @@ impl RenderProfileSelectionOption { Self::Off } } - - pub fn profile_id(self) -> String { - match self { - Self::Off => String::new(), - Self::Profile(id) => id, - } - } } impl std::fmt::Display for RenderProfileSelectionOption { diff --git a/configurator/src/models/config/setters.rs b/configurator/src/models/config/setters.rs index a2e2e1e9..f32d06fd 100644 --- a/configurator/src/models/config/setters.rs +++ b/configurator/src/models/config/setters.rs @@ -1,7 +1,7 @@ use super::super::fields::{ DragColorOption, DragMouseButton, DragToolField, DragToolOption, FontStyleOption, FontWeightOption, OverrideOption, QuadField, TextField, ToggleField, ToolbarLayoutModeOption, - ToolbarOverrideField, TripletField, + ToolbarOverrideField, }; use super::draft::ConfigDraft; use wayscriber::config::{ @@ -416,25 +416,6 @@ impl ConfigDraft { } } - pub fn set_triplet(&mut self, field: TripletField, index: usize, value: String) { - match field { - TripletField::DrawingColorRgb => { - if let Some(slot) = self.drawing_color.rgb.get_mut(index) { - *slot = value; - } - } - TripletField::QuickColorRgb(quick_color_index) => { - if let Some(component) = self - .drawing_quick_colors - .get_mut(quick_color_index) - .and_then(|entry| entry.color.rgb.get_mut(index)) - { - *component = value; - } - } - } - } - pub fn set_quad(&mut self, field: QuadField, index: usize, value: String) { match field { QuadField::StatusBarBg => self.status_bar_bg_color.set_component(index, value), diff --git a/configurator/src/models/config/tests.rs b/configurator/src/models/config/tests.rs index a7d6e3e3..ce317eb9 100644 --- a/configurator/src/models/config/tests.rs +++ b/configurator/src/models/config/tests.rs @@ -4,8 +4,7 @@ use super::super::fields::{ InputHudPositionOption, OverrideOption, PdfFitModeOption, PdfLabelContentModeOption, PdfOrientationOption, PdfPageSizeOption, PdfTransparentBackgroundOption, QuadField, ReducedMotionOption, SessionStorageModeOption, TextField, ToggleField, ToolOption, - ToolbarLayoutModeOption, ToolbarOverrideField, ToolbarRebindModifierOption, TripletField, - UiThemeOption, + ToolbarLayoutModeOption, ToolbarOverrideField, ToolbarRebindModifierOption, UiThemeOption, }; #[test] @@ -802,7 +801,6 @@ fn render_profile_selection_options_include_selectable_off() { RenderProfileSelectionOption::from_active("missing", &ids), RenderProfileSelectionOption::Off ); - assert_eq!(RenderProfileSelectionOption::Off.profile_id(), ""); } #[test] @@ -835,9 +833,6 @@ fn setters_update_draft_state() { assert_eq!(draft.drawing_color.name, "green"); assert_eq!(draft.drawing_color.selected_named, NamedColorOption::Green); - draft.set_triplet(TripletField::DrawingColorRgb, 1, "0.5".to_string()); - assert_eq!(draft.drawing_color.rgb[1], "0.5"); - draft.set_quad(QuadField::StatusBarBg, 2, "0.75".to_string()); assert_eq!(draft.status_bar_bg_color.components[2], "0.75"); diff --git a/configurator/src/models/fields/mod.rs b/configurator/src/models/fields/mod.rs index 97f0e79e..30ae83c2 100644 --- a/configurator/src/models/fields/mod.rs +++ b/configurator/src/models/fields/mod.rs @@ -25,9 +25,7 @@ pub use pressure::{PressureThicknessEditModeOption, PressureThicknessEntryModeOp pub use session::{SessionCompressionOption, SessionStorageModeOption}; pub use status::StatusPositionOption; pub use theme::{ReducedMotionOption, UiThemeOption}; -pub use toggles::{ - PresetTextField, PresetToggleField, QuadField, TextField, ToggleField, TripletField, -}; +pub use toggles::{PresetTextField, PresetToggleField, QuadField, TextField, ToggleField}; pub use tool::{DragColorOption, DragMouseButton, DragToolField, DragToolOption, ToolOption}; pub use toolbar::{ OverrideOption, ToolbarLayoutModeOption, ToolbarOverrideField, ToolbarRebindModifierOption, diff --git a/configurator/src/models/fields/toggles.rs b/configurator/src/models/fields/toggles.rs index 20823e18..0069db5d 100644 --- a/configurator/src/models/fields/toggles.rs +++ b/configurator/src/models/fields/toggles.rs @@ -83,7 +83,7 @@ pub enum ToggleField { } #[derive(Debug, Clone, Copy, PartialEq, Eq)] -/// Copy for cheap capture in iced callback closures. +/// Copy for cheap capture in GTK signal-handler closures. pub enum PresetToggleField { FillEnabled, TextBackgroundEnabled, @@ -173,7 +173,7 @@ pub enum TextField { } #[derive(Debug, Clone, Copy, PartialEq, Eq)] -/// Copy for cheap capture in iced callback closures. +/// Copy for cheap capture in GTK signal-handler closures. pub enum PresetTextField { Name, ColorName, @@ -184,12 +184,6 @@ pub enum PresetTextField { ArrowAngle, } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum TripletField { - DrawingColorRgb, - QuickColorRgb(usize), -} - #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum QuadField { StatusBarBg, diff --git a/configurator/src/models/mod.rs b/configurator/src/models/mod.rs index b0031210..bd6862e7 100644 --- a/configurator/src/models/mod.rs +++ b/configurator/src/models/mod.rs @@ -12,7 +12,7 @@ pub mod tab; pub mod util; pub use color::{ColorMode, ColorQuadInput, ColorTripletInput, NamedColorOption}; -pub use color_picker::{ColorPickerId, ColorPickerValue}; +pub use color_picker::ColorPickerId; pub use config::{ BoardBackgroundOption, BoardItemTextField, BoardItemToggleField, ConfigDraft, RenderProfileExportOption, RenderProfileMappingDraft, RenderProfileMappingSide, @@ -31,12 +31,13 @@ pub use fields::{ PresetToggleField, QuadField, ReducedMotionOption, SessionCompressionOption, SessionStorageModeOption, StatusPositionOption, TextField, ToggleField, ToolOption, ToolbarLayoutModeOption, ToolbarOverrideField, ToolbarRebindModifierOption, - ToolbarSideLayoutOption, TripletField, UiThemeOption, ZoomChipDisplayOption, + ToolbarSideLayoutOption, UiThemeOption, ZoomChipDisplayOption, }; #[cfg(feature = "tablet-input")] pub use fields::{PressureThicknessEditModeOption, PressureThicknessEntryModeOption}; pub use keybindings::KeybindingField; pub(crate) use search::SearchQuery; +pub(crate) use session::SessionCatalogOperation; pub use session::{SessionCatalogActionResult, SessionCatalogItem, SessionCatalogState}; pub(crate) use startup::{StartupRequest, startup_usage}; pub use tab::{KeybindingsTabId, TabId, UiTabId}; diff --git a/configurator/src/models/search.rs b/configurator/src/models/search.rs index 6a77c14b..23a88d0c 100644 --- a/configurator/src/models/search.rs +++ b/configurator/src/models/search.rs @@ -19,10 +19,6 @@ impl SearchQuery { &self.raw } - pub(crate) fn has_raw_input(&self) -> bool { - !self.raw.is_empty() - } - pub(crate) fn is_active(&self) -> bool { !self.tokens.is_empty() } @@ -76,17 +72,6 @@ mod tests { assert!(SearchQuery::new(" ").matches_text("Boards")); } - #[test] - fn raw_input_state_is_separate_from_active_tokens() { - let punctuation = SearchQuery::new("/"); - assert!(punctuation.has_raw_input()); - assert!(!punctuation.is_active()); - - let empty = SearchQuery::new(""); - assert!(!empty.has_raw_input()); - assert!(!empty.is_active()); - } - #[test] fn matches_all_tokens_across_parts() { let query = SearchQuery::new("pdf label"); diff --git a/configurator/src/models/session.rs b/configurator/src/models/session.rs index 15f62b79..a1e39670 100644 --- a/configurator/src/models/session.rs +++ b/configurator/src/models/session.rs @@ -4,6 +4,8 @@ use std::time::{Duration, UNIX_EPOCH}; use wayscriber::session::catalog::CatalogEntry; +use super::daemon::DaemonRuntimeStatus; + const PATH_LABEL_MAX_CHARS: usize = 96; #[derive(Debug, Clone, PartialEq, Eq)] @@ -42,6 +44,90 @@ impl SessionCatalogActionResult { } } +/// A saved-session operation whose availability depends on daemon/runtime +/// state. This pure policy is shared by update handlers, page sensitivity, +/// and the side-effect adapter that rechecks immediately before mutation. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum SessionCatalogOperation { + Clear, + ClearToolState, + Duplicate, + Move, +} + +impl SessionCatalogOperation { + pub(crate) fn cached_status_blocker( + self, + status: Option<&DaemonRuntimeStatus>, + ) -> Option<&'static str> { + match status { + Some(status) if status.service_active => Some(self.running_daemon_message()), + Some(_) => None, + None => Some(self.waiting_for_status_message()), + } + } + + pub(crate) fn running_daemon_message(self) -> &'static str { + match self { + Self::Clear => { + "Clear saved data is disabled while the background service or a manually started daemon is running. Stop it first or clear from the overlay." + } + Self::ClearToolState => { + "Clear saved tool state is disabled while the background service or a manually started daemon is running. Use the overlay command palette for the active session or stop it first." + } + Self::Duplicate => { + "Duplicate Session is disabled while the background service or a manually started daemon is running. Stop it first or duplicate from the overlay after opening the session." + } + Self::Move => { + "Move Session is disabled while the background service or a manually started daemon is running. Stop it first or move from the overlay after opening the session." + } + } + } + + pub(crate) fn running_overlay_message(self) -> &'static str { + match self { + Self::Clear => { + "Clear saved data is disabled while an overlay is running. Use the overlay Clear action for the active session." + } + Self::ClearToolState => { + "Clear saved tool state is disabled while an overlay is running. Use the command palette for the active session." + } + Self::Duplicate => { + "Duplicate Session is disabled while an overlay is running. Use Save As from the overlay for the active session." + } + Self::Move => { + "Move Session is disabled while an overlay is running. Active session moves must use a runtime transaction." + } + } + } + + pub(crate) fn waiting_for_status_message(self) -> &'static str { + match self { + Self::Clear => { + "Clear saved data is disabled until background service status finishes loading." + } + Self::ClearToolState => { + "Clear saved tool state is disabled until background service status finishes loading." + } + Self::Duplicate => { + "Duplicate Session is disabled until background service status finishes loading." + } + Self::Move => { + "Move Session is disabled until background service status finishes loading." + } + } + } + + pub(crate) fn label(self) -> &'static str { + match self { + Self::Clear => "Clear saved data", + Self::ClearToolState => "Clear saved tool state", + Self::Duplicate => "Duplicate Session", + Self::Move => "Move Session", + } + } +} + impl SessionCatalogState { pub fn loading() -> Self { Self { diff --git a/flake.nix b/flake.nix index ab9c18b1..e5196f52 100644 --- a/flake.nix +++ b/flake.nix @@ -76,16 +76,16 @@ cargoLock.lockFile = ./Cargo.lock; buildAndTestSubdir = "configurator"; - nativeBuildInputs = with pkgs; [ pkg-config makeWrapper ]; + nativeBuildInputs = with pkgs; [ pkg-config wrapGAppsHook4 ]; - # Iced GUI toolkit dependencies + # GTK4/libadwaita GUI toolkit dependencies buildInputs = with pkgs; [ cairo pango wayland libxkbcommon - vulkan-loader - libGL + gtk4 + libadwaita ]; postInstall = '' @@ -101,15 +101,6 @@ $out/share/icons/hicolor/scalable/apps/wayscriber-configurator.svg install -Dm644 README.md $out/share/doc/wayscriber-configurator/README.md install -Dm644 LICENSE $out/share/licenses/wayscriber-configurator/LICENSE - - # Wrap binary to find GL/Vulkan libraries at runtime (Iced uses dlopen) - wrapProgram $out/bin/wayscriber-configurator \ - --prefix LD_LIBRARY_PATH : ${pkgs.lib.makeLibraryPath [ - pkgs.vulkan-loader - pkgs.libGL - pkgs.wayland - pkgs.libxkbcommon - ]} ''; meta = with pkgs.lib; { @@ -138,8 +129,7 @@ libxkbcommon gtk4 gtk4-layer-shell - vulkan-loader - libGL + libadwaita ]; }; }); diff --git a/packaging/.SRCINFO b/packaging/.SRCINFO index 6d2c540e..540bdf09 100644 --- a/packaging/.SRCINFO +++ b/packaging/.SRCINFO @@ -15,6 +15,7 @@ pkgbase = wayscriber depends = glibc depends = gtk4 depends = gtk4-layer-shell + depends = libadwaita depends = wl-clipboard depends = grim depends = slurp diff --git a/packaging/PKGBUILD b/packaging/PKGBUILD index 63efa59e..e18afea1 100644 --- a/packaging/PKGBUILD +++ b/packaging/PKGBUILD @@ -15,6 +15,7 @@ depends=( 'glibc' 'gtk4' 'gtk4-layer-shell' + 'libadwaita' 'wl-clipboard' 'grim' 'slurp' diff --git a/packaging/package.configurator.yaml b/packaging/package.configurator.yaml index 107c9020..d06d3f32 100644 --- a/packaging/package.configurator.yaml +++ b/packaging/package.configurator.yaml @@ -7,7 +7,7 @@ maintainer: Wayscriber maintainers vendor: Wayscriber homepage: https://wayscriber.com license: MIT -description: GUI configurator for wayscriber (Iced) +description: GUI configurator for wayscriber (GTK4/libadwaita) contents: - src: target/release/wayscriber-configurator @@ -73,6 +73,8 @@ overrides: - libpango-1.0-0 - libpangocairo-1.0-0 - libxkbcommon0 + - libgtk-4-1 + - libadwaita-1-0 - libstdc++6 rpm: depends: @@ -82,6 +84,8 @@ overrides: - wayland-devel - pango - libxkbcommon + - gtk4 + - libadwaita - libstdc++ rpm: arch: x86_64 diff --git a/packaging/wayscriber-configurator.desktop b/packaging/wayscriber-configurator.desktop index 14e37000..e11ab2da 100644 --- a/packaging/wayscriber-configurator.desktop +++ b/packaging/wayscriber-configurator.desktop @@ -7,6 +7,7 @@ Exec=wayscriber-configurator TryExec=wayscriber-configurator Icon=wayscriber-configurator Terminal=false +StartupWMClass=org.wayscriber.Configurator Categories=Settings; Keywords=settings;configuration;draw;annotate;wayland;wayscriber; StartupNotify=false